feat(flutter): 시간 선택 시트와 휠·날짜 선택 등 공용 위젯 확장

일정 추가 화면에 필요한 공용 부품을 먼저 넣는다.
- 시간 선택 시트(오전/오후·시·분) — 시·분은 끝없이 돌아가는 휠, 조건이 안 맞으면 시트 안에 안내
- 날짜 선택 시트에 연도 범위 지정 (기간 설정은 올해~2050)
- 큰 알약 토글(일정 관리/다이어리)과 아이콘, 본문 서체 알약 버튼
- "직접 추가" 입력 칩을 공용으로 옮기고, 일반 일정 칩용 큰 × 추가
- HourMinute(시각 값), 주소 등 바깥 값용 날짜 파서

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:32:00 +09:00
parent 3e6206630f
commit f2a4451b92
10 changed files with 430 additions and 126 deletions
+7
View File
@@ -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)!));
}
+52
View File
@@ -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)
+15 -1
View File
@@ -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),
),
),
),
],
),
);
}
}
+59 -33
View File
@@ -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),
],
);
}
}
+19 -5
View File
@@ -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))),
),
),
);