rup-project/app/lib/widgets/common/custom_time_picker.dart

367 lines
11 KiB
Dart

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<TimeOfDay> onTimeChanged;
const CustomTimePicker({
super.key,
required this.initialTime,
required this.onTimeChanged,
});
@override
State<CustomTimePicker> createState() => _CustomTimePickerState();
}
class _CustomTimePickerState extends State<CustomTimePicker> {
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<String> 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,
),
),
),
],
),
);
}
}