import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; class CustomTimePicker extends StatefulWidget { final TimeOfDay initialTime; final ValueChanged onTimeChanged; const CustomTimePicker({ super.key, required this.initialTime, required this.onTimeChanged, }); @override State createState() => _CustomTimePickerState(); } class _CustomTimePickerState extends State { late FixedExtentScrollController _amPmController; late FixedExtentScrollController _hourController; late FixedExtentScrollController _minuteController; // 시간 직접 입력을 위한 컨트롤러 final TextEditingController _hourInputController = TextEditingController(); final TextEditingController _minuteInputController = TextEditingController(); // 입력 모드 상태 관리 bool _isEditingHour = false; bool _isEditingMinute = false; final FocusNode _hourFocusNode = FocusNode(); final FocusNode _minuteFocusNode = FocusNode(); // 내부 상태 (부모와 동기화) late int _selectedAmPm; // 0: AM, 1: PM late int _selectedHour; // 1~12 late int _selectedMinute; // 0~59 // 외부 업데이트 중인지 확인하는 플래그 bool _isSyncing = false; @override void initState() { super.initState(); _initializeState(); } @override void didUpdateWidget(CustomTimePicker oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.initialTime != widget.initialTime) { _isSyncing = true; // 동기화 시작 try { _updateInternalStateFromWidget(); // jumpToItem은 onSelectedItemChanged를 트리거할 수도 있으므로(구현에 따라 다름) // 플래그로 콜백 호출을 막습니다. if (_amPmController.hasClients) { _amPmController.jumpToItem(_selectedAmPm); } if (_hourController.hasClients) { _hourController.jumpToItem(_selectedHour - 1); } if (_minuteController.hasClients) { _minuteController.jumpToItem(_selectedMinute); } } finally { _isSyncing = false; // 동기화 종료 } } } void _initializeState() { _updateInternalStateFromWidget(); _amPmController = FixedExtentScrollController(initialItem: _selectedAmPm); _hourController = FixedExtentScrollController( initialItem: _selectedHour - 1, ); _minuteController = FixedExtentScrollController( initialItem: _selectedMinute, ); } void _updateInternalStateFromWidget() { final t = widget.initialTime; _selectedAmPm = t.period == DayPeriod.pm ? 1 : 0; _selectedHour = t.hourOfPeriod == 0 ? 12 : t.hourOfPeriod; _selectedMinute = t.minute; if (!_isEditingHour) { _hourInputController.text = _selectedHour.toString(); } if (!_isEditingMinute) { _minuteInputController.text = _selectedMinute.toString().padLeft(2, '0'); } } void _notifyTimeChanged() { if (_isSyncing) return; // 외부 동기화 중이면 부모에게 알리지 않음 // 내부 상태 -> TimeOfDay 변환 후 콜백 호출 int hour24 = _selectedHour; if (_selectedAmPm == 1 && _selectedHour < 12) { hour24 += 12; } else if (_selectedAmPm == 0 && _selectedHour == 12) { hour24 = 0; } widget.onTimeChanged(TimeOfDay(hour: hour24, minute: _selectedMinute)); } @override void dispose() { _amPmController.dispose(); _hourController.dispose(); _minuteController.dispose(); _hourInputController.dispose(); _minuteInputController.dispose(); _hourFocusNode.dispose(); _minuteFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 150.h, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // 오전/오후 _buildWheelPicker( controller: _amPmController, items: ['오전', '오후'], onChanged: (index) { if (_isSyncing) return; setState(() { _selectedAmPm = index; }); _notifyTimeChanged(); }, width: 60.w, height: 150.h, selectedIndex: _selectedAmPm, ), // 시 _buildWheelPicker( controller: _hourController, items: List.generate(12, (index) => (index + 1).toString()), onChanged: (index) { if (_isSyncing) return; setState(() { _selectedHour = index + 1; if (!_isEditingHour) { _hourInputController.text = _selectedHour.toString(); } }); _notifyTimeChanged(); }, width: 60.w, height: 150.h, isNumber: true, inputController: _hourInputController, isEditing: _isEditingHour, focusNode: _hourFocusNode, selectedIndex: _selectedHour - 1, onTap: () { setState(() { _isEditingHour = true; _isEditingMinute = false; }); _hourFocusNode.requestFocus(); }, onEditingComplete: () { setState(() => _isEditingHour = false); }, onInputChanged: (value) { if (value.isNotEmpty) { int? hour = int.tryParse(value); if (hour != null && hour >= 1 && hour <= 12) { setState(() => _selectedHour = hour); if (_hourController.hasClients) { _hourController.jumpToItem(hour - 1); } _notifyTimeChanged(); } // Auto-focus logic if (value.length == 2) { setState(() { _isEditingHour = false; _isEditingMinute = true; }); _minuteFocusNode.requestFocus(); } } }, ), Container( height: 150.h, alignment: Alignment.center, child: Text( ':', style: TextStyle( fontFamily: 'SCDream', fontSize: 24.sp, fontWeight: FontWeight.bold, color: Colors.black, ), ), ), // 분 _buildWheelPicker( controller: _minuteController, items: List.generate( 60, (index) => index.toString().padLeft(2, '0'), ), onChanged: (index) { if (_isSyncing) return; setState(() { _selectedMinute = index; if (!_isEditingMinute) { _minuteInputController.text = _selectedMinute .toString() .padLeft(2, '0'); } }); _notifyTimeChanged(); }, width: 60.w, height: 150.h, isNumber: true, inputController: _minuteInputController, isEditing: _isEditingMinute, focusNode: _minuteFocusNode, selectedIndex: _selectedMinute, onTap: () { setState(() { _isEditingMinute = true; _isEditingHour = false; }); _minuteFocusNode.requestFocus(); }, onEditingComplete: () { setState(() => _isEditingMinute = false); }, onInputChanged: (value) { if (value.isNotEmpty) { int? minute = int.tryParse(value); if (minute != null && minute >= 0 && minute <= 59) { setState(() => _selectedMinute = minute); if (_minuteController.hasClients) { _minuteController.jumpToItem(minute); } _notifyTimeChanged(); } } }, ), ], ), ); } Widget _buildWheelPicker({ required FixedExtentScrollController controller, required List items, required Function(int) onChanged, required double width, required double height, required int selectedIndex, bool isNumber = false, TextEditingController? inputController, Function(String)? onInputChanged, bool isEditing = false, FocusNode? focusNode, VoidCallback? onTap, VoidCallback? onEditingComplete, }) { return Container( width: width, height: height, child: Stack( children: [ CupertinoPicker( scrollController: controller, itemExtent: 50.h, onSelectedItemChanged: onChanged, selectionOverlay: null, diameterRatio: 99, squeeze: 1.1, children: items.asMap().entries.map((entry) { final index = entry.key; final item = entry.value; final isSelected = index == selectedIndex; return GestureDetector( onTap: () { if (isSelected) { if (isNumber) { onTap?.call(); } } else { controller.animateToItem( index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } }, behavior: HitTestBehavior.translucent, child: Center( child: Text( item, style: TextStyle( fontFamily: 'SCDream', fontSize: 20.sp, fontWeight: FontWeight.bold, color: Colors.black, ), ), ), ); }).toList(), ), if (isEditing && inputController != null && focusNode != null) Center( child: Container( width: width, height: 50.h, alignment: Alignment.center, decoration: const BoxDecoration(color: Colors.white), child: TextField( controller: inputController, focusNode: focusNode, keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 2, autofocus: true, onEditingComplete: onEditingComplete, onTapOutside: (_) => onEditingComplete?.call(), decoration: const InputDecoration( counterText: "", border: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true, ), style: TextStyle( fontFamily: 'SCDream', fontSize: 20.sp, fontWeight: FontWeight.bold, color: Colors.black, ), onChanged: onInputChanged, ), ), ), ], ), ); } }