generated from ttipo/project-base
feat(flutter): 시간 선택 시트와 휠·날짜 선택 등 공용 위젯 확장
일정 추가 화면에 필요한 공용 부품을 먼저 넣는다. - 시간 선택 시트(오전/오후·시·분) — 시·분은 끝없이 돌아가는 휠, 조건이 안 맞으면 시트 안에 안내 - 날짜 선택 시트에 연도 범위 지정 (기간 설정은 올해~2050) - 큰 알약 토글(일정 관리/다이어리)과 아이콘, 본문 서체 알약 버튼 - "직접 추가" 입력 칩을 공용으로 옮기고, 일반 일정 칩용 큰 × 추가 - HourMinute(시각 값), 주소 등 바깥 값용 날짜 파서 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,3 +30,10 @@ DateTime parseIsoDate(String iso) {
|
||||
final parts = iso.split('-').map(int.parse).toList();
|
||||
return DateTime(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
/// 주소 등 바깥에서 받은 값 — 형식이 틀리면 null
|
||||
DateTime? parseIsoDateOrNull(String? iso) {
|
||||
final m = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(iso ?? '');
|
||||
if (m == null) return null;
|
||||
return DateTime(int.parse(m.group(1)!), int.parse(m.group(2)!), int.parse(m.group(3)!));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/// 하루 안의 시각(시·분) — 저장은 24시간제 "HH:mm", 화면은 "오전 9:00"
|
||||
class HourMinute implements Comparable<HourMinute> {
|
||||
const HourMinute(this.hour, this.minute);
|
||||
|
||||
/// 자정부터의 분 → 시각 (하루를 넘으면 다음 날로 돌린다)
|
||||
factory HourMinute.fromMinutes(int minutes) {
|
||||
final m = minutes % _kMinutesPerDay;
|
||||
return HourMinute(m ~/ 60, m % 60);
|
||||
}
|
||||
|
||||
final int hour;
|
||||
final int minute;
|
||||
|
||||
int get totalMinutes => hour * 60 + minute;
|
||||
|
||||
bool get isAm => hour < 12;
|
||||
|
||||
/// 12시간제의 시 (0시·12시 → 12)
|
||||
int get hour12 => hour % 12 == 0 ? 12 : hour % 12;
|
||||
|
||||
/// "09:00" — 저장용
|
||||
String get hhmm => '${_pad(hour)}:${_pad(minute)}';
|
||||
|
||||
/// "오전 9:00" — 화면 표시용 (시안 fmtAP)
|
||||
String get label => '${isAm ? '오전' : '오후'} $hour12:${_pad(minute)}';
|
||||
|
||||
/// "HH:mm" → 시각. 형식이 틀리면 null
|
||||
static HourMinute? tryParse(String? text) {
|
||||
final m = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(text?.trim() ?? '');
|
||||
if (m == null) return null;
|
||||
final h = int.parse(m.group(1)!);
|
||||
final min = int.parse(m.group(2)!);
|
||||
if (h > 23 || min > 59) return null;
|
||||
return HourMinute(h, min);
|
||||
}
|
||||
|
||||
@override
|
||||
int compareTo(HourMinute other) => totalMinutes - other.totalMinutes;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is HourMinute && other.totalMinutes == totalMinutes;
|
||||
|
||||
@override
|
||||
int get hashCode => totalMinutes;
|
||||
|
||||
@override
|
||||
String toString() => hhmm;
|
||||
}
|
||||
|
||||
const int _kMinutesPerDay = 24 * 60;
|
||||
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/features/pet/data/repositories/allergen_menu_repository.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
|
||||
|
||||
const int _kCustomMaxLength = 12;
|
||||
const double _kCustomInputWidth = 76;
|
||||
|
||||
/// 알레르기 선택 — 기본 항목 + 직접 추가한 항목(메뉴에서 지울 수 있음) + 직접 추가
|
||||
class AllergenSelector extends StatefulWidget {
|
||||
@@ -68,77 +65,10 @@ class _AllergenSelectorState extends State<AllergenSelector> {
|
||||
onDelete: () => widget.onRemoveCustom(a),
|
||||
),
|
||||
if (_adding)
|
||||
_CustomInputChip(controller: _input, onSubmit: _commit)
|
||||
RupInputChip(controller: _input, maxLength: _kCustomMaxLength, onSubmit: _commit)
|
||||
else
|
||||
RupAddChip(label: '직접 추가', onTap: () => setState(() => _adding = true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomInputChip extends StatelessWidget {
|
||||
const _CustomInputChip({required this.controller, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 3, 5, 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
border: Border.all(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kCustomInputWidth,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: _kCustomMaxLength,
|
||||
style: style,
|
||||
cursorColor: AppColors.primary,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => onSubmit(),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
hintText: '직접 입력',
|
||||
hintStyle: style.copyWith(color: AppColors.muted),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '추가',
|
||||
child: GestureDetector(
|
||||
onTap: onSubmit,
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(AppSpacing.sm),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.check, size: 15, stroke: 2.6, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,45 @@ import 'package:tma_app/shared/widgets/wheel_picker.dart';
|
||||
/// 생년월일 선택 범위 — 시안: 올해부터 30년 전까지
|
||||
const int _kYearSpan = 30;
|
||||
|
||||
/// 시안 DatePicker — 연·월·일 휠 3개 + 바닥까지 채운 "선택" 버튼.
|
||||
/// 고르지 않고 닫으면 null.
|
||||
/// 날짜 선택 기본 안내 문구
|
||||
const String kDatePickerHint = '위아래로 돌려서 맞춰주세요';
|
||||
|
||||
/// 생년월일 선택 — 올해부터 30년 전까지
|
||||
Future<DateTime?> showBirthDatePicker(
|
||||
BuildContext context, {
|
||||
DateTime? initial,
|
||||
String title = '생년월일 선택',
|
||||
String hint = '위아래로 돌려서 맞춰주세요',
|
||||
String hint = kDatePickerHint,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final start = initial ?? DateTime(now.year - 1);
|
||||
return showRupDatePicker(
|
||||
context,
|
||||
initial: initial ?? DateTime(now.year - 1),
|
||||
title: title,
|
||||
hint: hint,
|
||||
firstYear: now.year - _kYearSpan,
|
||||
lastYear: now.year,
|
||||
);
|
||||
}
|
||||
|
||||
/// 시안 DatePicker — 연·월·일 휠 3개 + 바닥까지 채운 "선택" 버튼.
|
||||
/// 고르지 않고 닫으면 null.
|
||||
Future<DateTime?> showRupDatePicker(
|
||||
BuildContext context, {
|
||||
required DateTime initial,
|
||||
required String title,
|
||||
required int firstYear,
|
||||
required int lastYear,
|
||||
String hint = kDatePickerHint,
|
||||
}) {
|
||||
return showRupSheet<DateTime>(
|
||||
context,
|
||||
title: title,
|
||||
flushBottom: true,
|
||||
builder: (context) => _DatePickerBody(
|
||||
initial: start,
|
||||
firstYear: now.year - _kYearSpan,
|
||||
lastYear: now.year,
|
||||
initial: initial,
|
||||
firstYear: firstYear,
|
||||
lastYear: lastYear,
|
||||
hint: hint,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,11 +8,14 @@ import 'package:tma_app/core/constants/app_colors.dart';
|
||||
|
||||
/// 사람(보호자) 원형 아바타 — 사진이 없으면 기본 사람 아이콘
|
||||
class PersonAvatar extends StatelessWidget {
|
||||
const PersonAvatar({super.key, required this.size, this.photoPath});
|
||||
const PersonAvatar({super.key, required this.size, this.photoPath, this.background = AppColors.surface2});
|
||||
|
||||
final double size;
|
||||
final String? photoPath;
|
||||
|
||||
/// 사진이 없을 때 원 바탕 — 일정 대상 "나" 는 연주황(primary50)
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = photoPath;
|
||||
@@ -21,7 +24,7 @@ class PersonAvatar extends StatelessWidget {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(color: AppColors.surface2, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(color: background, shape: BoxShape.circle),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo == null
|
||||
? SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover)
|
||||
|
||||
@@ -40,12 +40,16 @@ class RupSoftPillButton extends StatelessWidget {
|
||||
required this.onPressed,
|
||||
this.showPlus = true,
|
||||
this.background = AppColors.surface2,
|
||||
this.uiFont = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final bool showPlus;
|
||||
|
||||
/// 본문 서체 600 — "+ 일정 추가" 는 시안이 버튼 서체를 덮어쓴다
|
||||
final bool uiFont;
|
||||
|
||||
/// 머리의 "+" 버튼은 더 옅은 바탕, 프로필 "편집"은 기본 .rup-btn--soft 바탕(primary50)
|
||||
final Color background;
|
||||
|
||||
@@ -67,7 +71,17 @@ class RupSoftPillButton extends StatelessWidget {
|
||||
const RupIcon(RupIconName.plus, size: 17, stroke: 2.2, color: AppColors.primary600),
|
||||
const SizedBox(width: 7),
|
||||
],
|
||||
Text(label, style: AppTypography.button(showPlus ? 13.5 : 13, color: AppColors.primary600)),
|
||||
Text(
|
||||
label,
|
||||
style: uiFont
|
||||
? Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
color: AppColors.primary600,
|
||||
)
|
||||
: AppTypography.button(showPlus ? 13.5 : 13, color: AppColors.primary600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -328,6 +328,7 @@ class RupTagChip extends StatelessWidget {
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.onDelete,
|
||||
this.largeDelete = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
@@ -337,6 +338,9 @@ class RupTagChip extends StatelessWidget {
|
||||
/// 지정하면 오른쪽에 × (메뉴에서 삭제)
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
/// 일반 일정 칩의 × — 조금 크고 글자와 더 떨어져 있다 (알레르기 칩: 17·4, 일반 일정: 18·9)
|
||||
final bool largeDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = selected ? AppColors.primary600 : AppColors.muted;
|
||||
@@ -369,17 +373,17 @@ class RupTagChip extends StatelessWidget {
|
||||
child: GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
width: 17,
|
||||
height: 17,
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x1A000000),
|
||||
width: largeDelete ? 18 : 17,
|
||||
height: largeDelete ? 18 : 17,
|
||||
margin: EdgeInsets.only(left: largeDelete ? 9 : 4),
|
||||
decoration: BoxDecoration(
|
||||
color: largeDelete ? const Color(0x17000000) : const Color(0x1A000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(
|
||||
child: RupIcon(
|
||||
RupIconName.x,
|
||||
size: 10,
|
||||
size: largeDelete ? 11 : 10,
|
||||
stroke: 2.6,
|
||||
color: AppColors.ink2,
|
||||
),
|
||||
@@ -440,3 +444,74 @@ TextStyle _chipText(BuildContext context, Color color) =>
|
||||
color: color,
|
||||
height: 1.2,
|
||||
);
|
||||
|
||||
/// "직접 추가" 를 누르면 나오는 입력 칩 — 입력 + ✓ 버튼
|
||||
const double _kInputChipWidth = 76;
|
||||
|
||||
class RupInputChip extends StatelessWidget {
|
||||
const RupInputChip({super.key, required this.controller, required this.onSubmit, required this.maxLength});
|
||||
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onSubmit;
|
||||
final int maxLength;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 3, 5, 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
border: Border.all(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kInputChipWidth,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: maxLength,
|
||||
style: style,
|
||||
cursorColor: AppColors.primary,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => onSubmit(),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
hintText: '직접 입력',
|
||||
hintStyle: style.copyWith(color: AppColors.muted),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '추가',
|
||||
child: GestureDetector(
|
||||
onTap: onSubmit,
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(AppSpacing.sm),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.check, size: 15, stroke: 2.6, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
const Duration _kSlide = Duration(milliseconds: 360);
|
||||
const Cubic _kSlideCurve = Cubic(.34, 1.42, .5, 1);
|
||||
const double _kPad = 3;
|
||||
const double _kPadLarge = 5;
|
||||
|
||||
/// 두 개 중 하나를 고르는 작은 알약 토글 (시안 .rup-segctl--sm) — 예: 간략히 / 상세히
|
||||
/// 두 개 중 하나를 고르는 알약 토글 — 작은 것(시안 .rup-segctl--sm, 간략히/상세히)과
|
||||
/// 가로를 채우는 큰 것([large], .rup-segctl — 일정 관리/다이어리)
|
||||
class SegToggle<T> extends StatelessWidget {
|
||||
const SegToggle({
|
||||
super.key,
|
||||
@@ -15,17 +17,24 @@ class SegToggle<T> extends StatelessWidget {
|
||||
required this.second,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.large = false,
|
||||
this.iconOf,
|
||||
});
|
||||
|
||||
final (T, String) first;
|
||||
final (T, String) second;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
final bool large;
|
||||
|
||||
/// 글자 앞 아이콘 (선택 여부에 따라 굵기를 바꿀 수 있게)
|
||||
final Widget Function(T value, bool selected)? iconOf;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onFirst = value == first.$1;
|
||||
final base = Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 12.5, height: 1.2);
|
||||
final base = Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: large ? 14.5 : 12.5, height: 1.2);
|
||||
final pad = large ? _kPadLarge : _kPad;
|
||||
|
||||
Widget button((T, String) option) {
|
||||
final on = option.$1 == value;
|
||||
@@ -37,14 +46,28 @@ class SegToggle<T> extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onChanged(option.$1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
|
||||
padding: large
|
||||
? const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: 10)
|
||||
: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 240),
|
||||
style: base.copyWith(
|
||||
color: on ? Colors.white : AppColors.ink2,
|
||||
fontWeight: on ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
child: Text(option.$2, textAlign: TextAlign.center, maxLines: 1),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (iconOf != null) ...[
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(color: on ? Colors.white : AppColors.ink2),
|
||||
child: iconOf!(option.$1, on),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
],
|
||||
Flexible(child: Text(option.$2, textAlign: TextAlign.center, maxLines: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -52,41 +75,44 @@ class SegToggle<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final stack = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedAlign(
|
||||
duration: _kSlide,
|
||||
curve: _kSlideCurve,
|
||||
alignment: onFirst ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: .5,
|
||||
heightFactor: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
boxShadow: large
|
||||
? const [
|
||||
BoxShadow(color: Color(0x52FF7500), offset: Offset(0, 4), blurRadius: 12),
|
||||
BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 1), blurRadius: 3),
|
||||
]
|
||||
: const [BoxShadow(color: Color(0x4DFF7500), offset: Offset(0, 2), blurRadius: 7)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(children: [button(first), button(second)]),
|
||||
],
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(_kPad),
|
||||
padding: EdgeInsets.all(pad),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface2,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
border: Border.all(color: AppColors.line2),
|
||||
),
|
||||
// 두 칸의 폭을 긴 쪽에 맞춰 같게 한다 (grid 1fr 1fr)
|
||||
child: IntrinsicWidth(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedAlign(
|
||||
duration: _kSlide,
|
||||
curve: _kSlideCurve,
|
||||
alignment: onFirst ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: .5,
|
||||
heightFactor: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x4DFF7500), offset: Offset(0, 2), blurRadius: 7),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(children: [button(first), button(second)]),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 작은 것은 두 칸의 폭을 긴 쪽에 맞춰 같게 한다 (grid 1fr 1fr), 큰 것은 가로를 채운다
|
||||
child: large ? stack : IntrinsicWidth(child: stack),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/core/utils/hour_minute.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_sheet.dart';
|
||||
import 'package:tma_app/shared/widgets/wheel_picker.dart';
|
||||
|
||||
/// 시안 TimePicker 치수 — 3줄 휠, 오전/오후 열 폭 64
|
||||
const int _kRows = 3;
|
||||
const double _kMeridiemWidth = 64;
|
||||
const double _kBandRadius = 14;
|
||||
const HourMinute _kDefaultTime = HourMinute(14, 0);
|
||||
|
||||
/// 오류 안내 상자 색 (시안 #FFF1F1 / #B3261E)
|
||||
const Color _kErrorBg = Color(0xFFFFF1F1);
|
||||
const Color _kErrorText = Color(0xFFB3261E);
|
||||
|
||||
const List<String> _kMeridiem = ['오전', '오후'];
|
||||
|
||||
/// 시간 선택 시트 — 오전/오후 · 시 · 분 휠. 고르지 않고 닫으면 null.
|
||||
/// [validate] 가 문구를 돌려주면 시트 안에 안내하고 닫지 않는다 (예: 종료가 시작보다 이를 때).
|
||||
Future<HourMinute?> showTimePickerSheet(
|
||||
BuildContext context, {
|
||||
String title = '시간 선택',
|
||||
HourMinute? initial,
|
||||
String? Function(HourMinute value)? validate,
|
||||
}) {
|
||||
return showRupSheet<HourMinute>(
|
||||
context,
|
||||
title: title,
|
||||
flushBottom: true,
|
||||
builder: (context) => _TimePickerBody(initial: initial ?? _kDefaultTime, validate: validate),
|
||||
);
|
||||
}
|
||||
|
||||
class _TimePickerBody extends StatefulWidget {
|
||||
const _TimePickerBody({required this.initial, required this.validate});
|
||||
|
||||
final HourMinute initial;
|
||||
final String? Function(HourMinute value)? validate;
|
||||
|
||||
@override
|
||||
State<_TimePickerBody> createState() => _TimePickerBodyState();
|
||||
}
|
||||
|
||||
class _TimePickerBodyState extends State<_TimePickerBody> {
|
||||
late bool _am = widget.initial.isAm;
|
||||
late int _h12 = widget.initial.hour12;
|
||||
late int _m = widget.initial.minute;
|
||||
String? _error;
|
||||
|
||||
HourMinute get _value => HourMinute((_h12 % 12) + (_am ? 0 : 12), _m);
|
||||
|
||||
void _change(VoidCallback f) => setState(() {
|
||||
f();
|
||||
_error = null;
|
||||
});
|
||||
|
||||
void _confirm() {
|
||||
final error = widget.validate?.call(_value);
|
||||
if (error != null) {
|
||||
setState(() => _error = error);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(_value);
|
||||
}
|
||||
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const bandTop = kWheelItemExtent * (_kRows - 1) / 2;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_error != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: AppSpacing.sm),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 9),
|
||||
decoration: BoxDecoration(color: _kErrorBg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _kErrorText,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 2),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 세 열이 함께 쓰는 선택 띠
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: bandTop,
|
||||
height: kWheelItemExtent,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary50,
|
||||
borderRadius: BorderRadius.circular(_kBandRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kMeridiemWidth,
|
||||
child: WheelColumn<String>(
|
||||
items: _kMeridiem,
|
||||
value: _am ? _kMeridiem.first : _kMeridiem.last,
|
||||
labelOf: (v) => v,
|
||||
rows: _kRows,
|
||||
showBand: false,
|
||||
onChanged: (v) => _change(() => _am = v == _kMeridiem.first),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: WheelColumn<int>(
|
||||
items: [for (var h = 1; h <= 12; h++) h],
|
||||
value: _h12,
|
||||
labelOf: _pad,
|
||||
suffix: '시',
|
||||
rows: _kRows,
|
||||
showBand: false,
|
||||
loop: true,
|
||||
onChanged: (h) => _change(() => _h12 = h),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
':',
|
||||
style: TextStyle(fontFamily: AppFonts.display, fontSize: 24, color: AppColors.ink2),
|
||||
),
|
||||
Expanded(
|
||||
child: WheelColumn<int>(
|
||||
items: [for (var m = 0; m < 60; m++) m],
|
||||
value: _m,
|
||||
labelOf: _pad,
|
||||
suffix: '분',
|
||||
rows: _kRows,
|
||||
showBand: false,
|
||||
loop: true,
|
||||
onChanged: (m) => _change(() => _m = m),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
RupSheetBleedButton(label: '${_value.label} 선택', onPressed: _confirm),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class WheelColumn<T> extends StatefulWidget {
|
||||
this.suffix,
|
||||
this.rows = _kDefaultRows,
|
||||
this.showBand = true,
|
||||
this.loop = false,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
@@ -36,19 +37,32 @@ class WheelColumn<T> extends StatefulWidget {
|
||||
/// 가운데 선택 띠를 이 열이 직접 그릴지 (여러 열이 한 띠를 공유하면 false)
|
||||
final bool showBand;
|
||||
|
||||
/// 끝이 없이 돌아가는 열 — 시·분 (시안 loop)
|
||||
final bool loop;
|
||||
|
||||
@override
|
||||
State<WheelColumn<T>> createState() => _WheelColumnState<T>();
|
||||
}
|
||||
|
||||
/// 끝없는 열은 가운데쯤에서 시작해 위로도 충분히 굴릴 수 있게 한다
|
||||
const int _kLoopLaps = 1000;
|
||||
|
||||
class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
late final FixedExtentScrollController _controller = FixedExtentScrollController(
|
||||
initialItem: _indexOf(widget.value),
|
||||
);
|
||||
|
||||
/// 굴린 위치 (끝없는 열이면 목록 길이를 넘는다 — 항목은 나머지로 구한다)
|
||||
late int _selected = _indexOf(widget.value);
|
||||
|
||||
int get _count => widget.items.length;
|
||||
|
||||
T _itemAt(int index) => widget.items[index % _count];
|
||||
|
||||
int _indexOf(T value) {
|
||||
final i = widget.items.indexOf(value);
|
||||
return i < 0 ? 0 : i;
|
||||
final base = i < 0 ? 0 : i;
|
||||
return widget.loop ? _count * _kLoopLaps + base : base;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -59,7 +73,7 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
|
||||
void _select(int index) {
|
||||
setState(() => _selected = index);
|
||||
final value = widget.items[index];
|
||||
final value = _itemAt(index);
|
||||
if (value != widget.value) widget.onChanged(value);
|
||||
}
|
||||
|
||||
@@ -67,7 +81,7 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
Widget build(BuildContext context) {
|
||||
final height = kWheelItemExtent * widget.rows;
|
||||
final bandTop = kWheelItemExtent * (widget.rows - 1) / 2;
|
||||
final selectedLabel = widget.labelOf(widget.items[_selected]);
|
||||
final selectedLabel = widget.labelOf(_itemAt(_selected));
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
@@ -89,7 +103,7 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
physics: const FixedExtentScrollPhysics(),
|
||||
onSelectedItemChanged: _select,
|
||||
childDelegate: ListWheelChildBuilderDelegate(
|
||||
childCount: widget.items.length,
|
||||
childCount: widget.loop ? null : _count,
|
||||
builder: (context, index) {
|
||||
final on = index == _selected;
|
||||
return GestureDetector(
|
||||
@@ -110,7 +124,7 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
? AppColors.primary600
|
||||
: AppColors.muted.withValues(alpha: _kIdleOpacity),
|
||||
),
|
||||
child: Text(widget.labelOf(widget.items[index])),
|
||||
child: Text(widget.labelOf(_itemAt(index))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user