341 lines
11 KiB
Dart
341 lines
11 KiB
Dart
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
/// 调用示例:
|
||
/// DateTime? picked = await BottomDateTimePicker.showDate(
|
||
/// context,
|
||
/// allowFuture: true, // 添加此参数允许选择未来时间
|
||
/// minTimeStr: '2025-08-20 08:30', // 可选:不允许选择早于此时间
|
||
/// );
|
||
/// if (picked != null) {
|
||
/// print('用户选择的时间:$picked');
|
||
/// }
|
||
class BottomDateTimePicker {
|
||
static Future<DateTime?> showDate(
|
||
BuildContext context, {
|
||
bool allowFuture = false,
|
||
String? minTimeStr, // 新增:可选起始时间格式 'yyyy-MM-dd HH:mm'
|
||
}) {
|
||
return showModalBottomSheet<DateTime>(
|
||
context: context,
|
||
backgroundColor: Colors.white,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||
),
|
||
builder: (_) => _InlineDateTimePickerContent(
|
||
allowFuture: allowFuture,
|
||
minTimeStr: minTimeStr,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InlineDateTimePickerContent extends StatefulWidget {
|
||
final bool allowFuture; // 允许未来
|
||
final String? minTimeStr; // 新增:最小允许时间字符串 'yyyy-MM-dd HH:mm'
|
||
|
||
const _InlineDateTimePickerContent({
|
||
Key? key,
|
||
this.allowFuture = false,
|
||
this.minTimeStr,
|
||
}) : super(key: key);
|
||
|
||
@override
|
||
State<_InlineDateTimePickerContent> createState() =>
|
||
_InlineDateTimePickerContentState();
|
||
}
|
||
|
||
class _InlineDateTimePickerContentState
|
||
extends State<_InlineDateTimePickerContent> {
|
||
// 数据源
|
||
final List<int> years = List.generate(101, (i) => 1970 + i);
|
||
final List<int> months = List.generate(12, (i) => i + 1);
|
||
final List<int> hours = List.generate(24, (i) => i);
|
||
final List<int> minutes = List.generate(60, (i) => i);
|
||
|
||
// 动态天数列表(根据年月变化)
|
||
late List<int> days;
|
||
|
||
// Controllers
|
||
late FixedExtentScrollController yearCtrl;
|
||
late FixedExtentScrollController monthCtrl;
|
||
late FixedExtentScrollController dayCtrl;
|
||
late FixedExtentScrollController hourCtrl;
|
||
late FixedExtentScrollController minuteCtrl;
|
||
|
||
// 当前选中值
|
||
late int selectedYear;
|
||
late int selectedMonth;
|
||
late int selectedDay;
|
||
late int selectedHour;
|
||
late int selectedMinute;
|
||
|
||
DateTime? _minTime; // 解析后的最小允许时间(如果有)
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
// 解析 minTimeStr(若提供)
|
||
_minTime = _parseMinTime(widget.minTimeStr);
|
||
|
||
// 选择初始时间:取 DateTime.now() 与 _minTime 的较大者(确保初始选中合法)
|
||
final now = DateTime.now();
|
||
DateTime initial = now;
|
||
if (_minTime != null && _minTime!.isAfter(initial)) {
|
||
initial = _minTime!;
|
||
}
|
||
|
||
selectedYear = initial.year;
|
||
selectedMonth = initial.month;
|
||
selectedDay = initial.day;
|
||
selectedHour = initial.hour;
|
||
selectedMinute = initial.minute;
|
||
|
||
// 初始化天数列表
|
||
days = _getDaysInMonth(selectedYear, selectedMonth);
|
||
|
||
// controllers 初始项索引需在范围内
|
||
yearCtrl = FixedExtentScrollController(
|
||
initialItem: years.indexOf(selectedYear).clamp(0, years.length - 1));
|
||
monthCtrl = FixedExtentScrollController(initialItem: (selectedMonth - 1).clamp(0, months.length - 1));
|
||
dayCtrl = FixedExtentScrollController(initialItem: (selectedDay - 1).clamp(0, days.length - 1));
|
||
hourCtrl = FixedExtentScrollController(initialItem: selectedHour.clamp(0, hours.length - 1));
|
||
minuteCtrl = FixedExtentScrollController(initialItem: selectedMinute.clamp(0, minutes.length - 1));
|
||
|
||
// 如果初始时间小于 minTime(理论上不会,因为我们取了较大者),再修正一次
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_enforceConstraintsAndUpdateControllers();
|
||
});
|
||
}
|
||
|
||
// 根据年月获取当月天数
|
||
List<int> _getDaysInMonth(int year, int month) {
|
||
final lastDay = DateUtils.getDaysInMonth(year, month);
|
||
return List.generate(lastDay, (i) => i + 1);
|
||
}
|
||
|
||
// 解析 'yyyy-MM-dd HH:mm' 返回 DateTime 或 null
|
||
DateTime? _parseMinTime(String? s) {
|
||
if (s == null || s.trim().isEmpty) return null;
|
||
try {
|
||
final trimmed = s.trim();
|
||
final parts = trimmed.split(' ');
|
||
final dateParts = parts[0].split('-').map((e) => int.parse(e)).toList();
|
||
final timeParts = (parts.length > 1)
|
||
? parts[1].split(':').map((e) => int.parse(e)).toList()
|
||
: [0, 0];
|
||
final year = dateParts[0];
|
||
final month = dateParts[1];
|
||
final day = dateParts[2];
|
||
final hour = (timeParts.isNotEmpty) ? timeParts[0] : 0;
|
||
final minute = (timeParts.length > 1) ? timeParts[1] : 0;
|
||
return DateTime(year, month, day, hour, minute);
|
||
} catch (e) {
|
||
// 解析失败则忽略
|
||
debugPrint('parseMinTime failed for "$s": $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 更新天数列表并调整选中日期
|
||
void _updateDays({bool jumpDay = true}) {
|
||
final newDays = _getDaysInMonth(selectedYear, selectedMonth);
|
||
final isDayValid = selectedDay <= newDays.length;
|
||
|
||
setState(() {
|
||
days = newDays;
|
||
if (!isDayValid) {
|
||
selectedDay = newDays.last;
|
||
if (jumpDay) dayCtrl.jumpToItem(selectedDay - 1);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 检查并限制时间:
|
||
// 1) 优先检查 _minTime(如果存在),不允许早于 _minTime
|
||
// 2) 如果没有 _minTime,则根据 allowFuture 决定是否限制到 now
|
||
// 注意:_minTime 优先级高于 allowFuture(如果 _minTime 在未来,会选择 _minTime)
|
||
void _enforceConstraintsAndUpdateControllers() {
|
||
final picked = DateTime(selectedYear, selectedMonth, selectedDay, selectedHour, selectedMinute);
|
||
|
||
// 1) 最小时间约束(优先)
|
||
if (_minTime != null && picked.isBefore(_minTime!)) {
|
||
final m = _minTime!;
|
||
selectedYear = m.year;
|
||
selectedMonth = m.month;
|
||
selectedDay = m.day;
|
||
selectedHour = m.hour;
|
||
selectedMinute = m.minute;
|
||
|
||
// 更新天数列表与控制器索引
|
||
_updateDays(jumpDay: false);
|
||
yearCtrl.jumpToItem(years.indexOf(selectedYear));
|
||
monthCtrl.jumpToItem(selectedMonth - 1);
|
||
dayCtrl.jumpToItem(selectedDay - 1);
|
||
hourCtrl.jumpToItem(selectedHour);
|
||
minuteCtrl.jumpToItem(selectedMinute);
|
||
return;
|
||
}
|
||
|
||
// 2) 禁止选择未来(当 allowFuture == false 且没有 minTime 或 minTime <= now)
|
||
if (!widget.allowFuture) {
|
||
final now = DateTime.now();
|
||
// 如果 minTime 存在并大于 now,我们已在上面处理(minTime 优先),所以这里处理的是普通情况
|
||
if (picked.isAfter(now)) {
|
||
selectedYear = now.year;
|
||
selectedMonth = now.month;
|
||
selectedDay = now.day;
|
||
selectedHour = now.hour;
|
||
selectedMinute = now.minute;
|
||
|
||
_updateDays(jumpDay: false);
|
||
yearCtrl.jumpToItem(years.indexOf(selectedYear));
|
||
monthCtrl.jumpToItem(selectedMonth - 1);
|
||
dayCtrl.jumpToItem(selectedDay - 1);
|
||
hourCtrl.jumpToItem(selectedHour);
|
||
minuteCtrl.jumpToItem(selectedMinute);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
yearCtrl.dispose();
|
||
monthCtrl.dispose();
|
||
dayCtrl.dispose();
|
||
hourCtrl.dispose();
|
||
minuteCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 330,
|
||
child: Column(
|
||
children: [
|
||
// 顶部按钮
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text("取消", style: TextStyle(color: Colors.grey)),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
final result = DateTime(
|
||
selectedYear,
|
||
selectedMonth,
|
||
selectedDay,
|
||
selectedHour,
|
||
selectedMinute,
|
||
);
|
||
Navigator.of(context).pop(result);
|
||
},
|
||
child: const Text("确定", style: TextStyle(color: Colors.blue)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
|
||
// 五列数字滚轮
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
// 年
|
||
_buildPicker(
|
||
controller: yearCtrl,
|
||
items: years.map((e) => e.toString()).toList(),
|
||
onSelected: (idx) {
|
||
setState(() {
|
||
selectedYear = years[idx];
|
||
_updateDays();
|
||
_enforceConstraintsAndUpdateControllers();
|
||
});
|
||
},
|
||
),
|
||
|
||
// 月
|
||
_buildPicker(
|
||
controller: monthCtrl,
|
||
items: months.map((e) => e.toString().padLeft(2, '0')).toList(),
|
||
onSelected: (idx) {
|
||
setState(() {
|
||
selectedMonth = months[idx];
|
||
_updateDays();
|
||
_enforceConstraintsAndUpdateControllers();
|
||
});
|
||
},
|
||
),
|
||
|
||
// 日
|
||
_buildPicker(
|
||
controller: dayCtrl,
|
||
items: days.map((e) => e.toString().padLeft(2, '0')).toList(),
|
||
onSelected: (idx) {
|
||
setState(() {
|
||
// 防护:idx 可能超出当前 days 长度(极小概率)
|
||
final safeIdx = idx.clamp(0, days.length - 1);
|
||
selectedDay = days[safeIdx];
|
||
_enforceConstraintsAndUpdateControllers();
|
||
});
|
||
},
|
||
),
|
||
|
||
// 时
|
||
_buildPicker(
|
||
controller: hourCtrl,
|
||
items: hours.map((e) => e.toString().padLeft(2, '0')).toList(),
|
||
onSelected: (idx) {
|
||
setState(() {
|
||
selectedHour = hours[idx];
|
||
_enforceConstraintsAndUpdateControllers();
|
||
});
|
||
},
|
||
),
|
||
|
||
// 分
|
||
_buildPicker(
|
||
controller: minuteCtrl,
|
||
items: minutes.map((e) => e.toString().padLeft(2, '0')).toList(),
|
||
onSelected: (idx) {
|
||
setState(() {
|
||
selectedMinute = minutes[idx];
|
||
_enforceConstraintsAndUpdateControllers();
|
||
});
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPicker({
|
||
required FixedExtentScrollController controller,
|
||
required List<String> items,
|
||
required ValueChanged<int> onSelected,
|
||
}) {
|
||
return Expanded(
|
||
child: CupertinoPicker.builder(
|
||
scrollController: controller,
|
||
itemExtent: 32,
|
||
childCount: items.length,
|
||
onSelectedItemChanged: onSelected,
|
||
itemBuilder: (context, index) {
|
||
return Center(child: Text(items[index]));
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|