367 lines
11 KiB
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|