diff --git a/flutter/lib/core/utils/date_format.dart b/flutter/lib/core/utils/date_format.dart index 180f5cd..31cd800 100644 --- a/flutter/lib/core/utils/date_format.dart +++ b/flutter/lib/core/utils/date_format.dart @@ -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)!)); +} diff --git a/flutter/lib/core/utils/hour_minute.dart b/flutter/lib/core/utils/hour_minute.dart new file mode 100644 index 0000000..3db497b --- /dev/null +++ b/flutter/lib/core/utils/hour_minute.dart @@ -0,0 +1,52 @@ +/// 하루 안의 시각(시·분) — 저장은 24시간제 "HH:mm", 화면은 "오전 9:00" +class HourMinute implements Comparable { + 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'); diff --git a/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart b/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart index a14d0b7..0fe42ad 100644 --- a/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart +++ b/flutter/lib/features/pet/presentation/widgets/allergen_selector.dart @@ -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 { 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), - ), - ), - ), - ], - ), - ); - } -} diff --git a/flutter/lib/shared/widgets/date_picker_sheet.dart b/flutter/lib/shared/widgets/date_picker_sheet.dart index 3bffb47..10311fe 100644 --- a/flutter/lib/shared/widgets/date_picker_sheet.dart +++ b/flutter/lib/shared/widgets/date_picker_sheet.dart @@ -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 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 showRupDatePicker( + BuildContext context, { + required DateTime initial, + required String title, + required int firstYear, + required int lastYear, + String hint = kDatePickerHint, +}) { return showRupSheet( 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, ), ); diff --git a/flutter/lib/shared/widgets/person_avatar.dart b/flutter/lib/shared/widgets/person_avatar.dart index 0b95f52..247f2a9 100644 --- a/flutter/lib/shared/widgets/person_avatar.dart +++ b/flutter/lib/shared/widgets/person_avatar.dart @@ -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) diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart index 9f796a1..a692644 100644 --- a/flutter/lib/shared/widgets/rup_button.dart +++ b/flutter/lib/shared/widgets/rup_button.dart @@ -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), + ), ], ), ), diff --git a/flutter/lib/shared/widgets/rup_form_fields.dart b/flutter/lib/shared/widgets/rup_form_fields.dart index 5884fc2..b8c4a14 100644 --- a/flutter/lib/shared/widgets/rup_form_fields.dart +++ b/flutter/lib/shared/widgets/rup_form_fields.dart @@ -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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/seg_toggle.dart b/flutter/lib/shared/widgets/seg_toggle.dart index f9c1684..77092cb 100644 --- a/flutter/lib/shared/widgets/seg_toggle.dart +++ b/flutter/lib/shared/widgets/seg_toggle.dart @@ -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 extends StatelessWidget { const SegToggle({ super.key, @@ -15,17 +17,24 @@ class SegToggle 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 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 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 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), ); } } diff --git a/flutter/lib/shared/widgets/time_picker_sheet.dart b/flutter/lib/shared/widgets/time_picker_sheet.dart new file mode 100644 index 0000000..902f5bd --- /dev/null +++ b/flutter/lib/shared/widgets/time_picker_sheet.dart @@ -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 _kMeridiem = ['오전', '오후']; + +/// 시간 선택 시트 — 오전/오후 · 시 · 분 휠. 고르지 않고 닫으면 null. +/// [validate] 가 문구를 돌려주면 시트 안에 안내하고 닫지 않는다 (예: 종료가 시작보다 이를 때). +Future showTimePickerSheet( + BuildContext context, { + String title = '시간 선택', + HourMinute? initial, + String? Function(HourMinute value)? validate, +}) { + return showRupSheet( + 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( + 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( + 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( + 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), + ], + ); + } +} diff --git a/flutter/lib/shared/widgets/wheel_picker.dart b/flutter/lib/shared/widgets/wheel_picker.dart index e3da94a..aaa962e 100644 --- a/flutter/lib/shared/widgets/wheel_picker.dart +++ b/flutter/lib/shared/widgets/wheel_picker.dart @@ -22,6 +22,7 @@ class WheelColumn extends StatefulWidget { this.suffix, this.rows = _kDefaultRows, this.showBand = true, + this.loop = false, }); final List items; @@ -36,19 +37,32 @@ class WheelColumn extends StatefulWidget { /// 가운데 선택 띠를 이 열이 직접 그릴지 (여러 열이 한 띠를 공유하면 false) final bool showBand; + /// 끝이 없이 돌아가는 열 — 시·분 (시안 loop) + final bool loop; + @override State> createState() => _WheelColumnState(); } +/// 끝없는 열은 가운데쯤에서 시작해 위로도 충분히 굴릴 수 있게 한다 +const int _kLoopLaps = 1000; + class _WheelColumnState extends State> { 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 extends State> { 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 extends State> { 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 extends State> { 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 extends State> { ? AppColors.primary600 : AppColors.muted.withValues(alpha: _kIdleOpacity), ), - child: Text(widget.labelOf(widget.items[index])), + child: Text(widget.labelOf(_itemAt(index))), ), ), );