2025-07-28 14:22:07 +08:00
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
|
|
/// 调用示例:
|
2025-08-14 18:14:15 +08:00
|
|
|
|
/// DateTime? picked = await BottomDateTimePicker.showDate(
|
|
|
|
|
/// context,
|
2025-08-29 09:52:48 +08:00
|
|
|
|
/// mode: BottomPickerMode.date, // 或 BottomPickerMode.dateTime(默认)
|
|
|
|
|
/// allowFuture: true,
|
|
|
|
|
/// minTimeStr: '2025-08-20 08:30',
|
2025-08-14 18:14:15 +08:00
|
|
|
|
/// );
|
2025-07-28 14:22:07 +08:00
|
|
|
|
/// if (picked != null) {
|
|
|
|
|
/// print('用户选择的时间:$picked');
|
|
|
|
|
/// }
|
2025-08-29 09:52:48 +08:00
|
|
|
|
enum BottomPickerMode { dateTime, date }
|
|
|
|
|
|
2025-07-28 14:22:07 +08:00
|
|
|
|
class BottomDateTimePicker {
|
2025-08-21 16:44:24 +08:00
|
|
|
|
static Future<DateTime?> showDate(
|
|
|
|
|
BuildContext context, {
|
|
|
|
|
bool allowFuture = false,
|
2025-08-29 09:52:48 +08:00
|
|
|
|
String? minTimeStr, // 可选:'yyyy-MM-dd HH:mm'
|
|
|
|
|
BottomPickerMode mode = BottomPickerMode.dateTime,
|
2025-08-21 16:44:24 +08:00
|
|
|
|
}) {
|
2025-07-28 14:22:07 +08:00
|
|
|
|
return showModalBottomSheet<DateTime>(
|
|
|
|
|
context: context,
|
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
|
isScrollControlled: true,
|
|
|
|
|
shape: const RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
|
|
|
),
|
2025-08-21 16:44:24 +08:00
|
|
|
|
builder: (_) => _InlineDateTimePickerContent(
|
|
|
|
|
allowFuture: allowFuture,
|
|
|
|
|
minTimeStr: minTimeStr,
|
2025-08-29 09:52:48 +08:00
|
|
|
|
mode: mode,
|
2025-08-21 16:44:24 +08:00
|
|
|
|
),
|
2025-07-28 14:22:07 +08:00
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _InlineDateTimePickerContent extends StatefulWidget {
|
2025-08-29 09:52:48 +08:00
|
|
|
|
final bool allowFuture;
|
|
|
|
|
final String? minTimeStr;
|
|
|
|
|
final BottomPickerMode mode;
|
2025-08-14 18:14:15 +08:00
|
|
|
|
|
2025-08-21 16:44:24 +08:00
|
|
|
|
const _InlineDateTimePickerContent({
|
|
|
|
|
Key? key,
|
|
|
|
|
this.allowFuture = false,
|
|
|
|
|
this.minTimeStr,
|
2025-08-29 09:52:48 +08:00
|
|
|
|
this.mode = BottomPickerMode.dateTime,
|
2025-08-21 16:44:24 +08:00
|
|
|
|
}) : super(key: key);
|
2025-08-14 18:14:15 +08:00
|
|
|
|
|
2025-07-28 14:22:07 +08:00
|
|
|
|
@override
|
2025-08-21 16:44:24 +08:00
|
|
|
|
State<_InlineDateTimePickerContent> createState() =>
|
|
|
|
|
_InlineDateTimePickerContentState();
|
2025-07-28 14:22:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 16:44:24 +08:00
|
|
|
|
class _InlineDateTimePickerContentState
|
|
|
|
|
extends State<_InlineDateTimePickerContent> {
|
2025-07-28 14:22:07 +08:00
|
|
|
|
// 数据源
|
|
|
|
|
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);
|
|
|
|
|
|
2025-08-14 18:14:15 +08:00
|
|
|
|
// 动态天数列表(根据年月变化)
|
|
|
|
|
late List<int> days;
|
|
|
|
|
|
2025-07-28 14:22:07 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
2025-08-21 16:44:24 +08:00
|
|
|
|
DateTime? _minTime; // 解析后的最小允许时间(如果有)
|
|
|
|
|
|
2025-07-28 14:22:07 +08:00
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
2025-08-21 16:44:24 +08:00
|
|
|
|
|
|
|
|
|
// 解析 minTimeStr(若提供)
|
|
|
|
|
_minTime = _parseMinTime(widget.minTimeStr);
|
|
|
|
|
|
2025-08-29 09:52:48 +08:00
|
|
|
|
// 初始时间:取 now 与 _minTime 的较大者
|
2025-07-28 14:22:07 +08:00
|
|
|
|
final now = DateTime.now();
|
2025-08-21 16:44:24 +08:00
|
|
|
|
DateTime initial = now;
|
|
|
|
|
if (_minTime != null && _minTime!.isAfter(initial)) {
|
|
|
|
|
initial = _minTime!;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 09:52:48 +08:00
|
|
|
|
// 如果是 date 模式,只保留日期部分(时分归零)
|
|
|
|
|
if (widget.mode == BottomPickerMode.date) {
|
|
|
|
|
initial = DateTime(initial.year, initial.month, initial.day);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 16:44:24 +08:00
|
|
|
|
selectedYear = initial.year;
|
|
|
|
|
selectedMonth = initial.month;
|
|
|
|
|
selectedDay = initial.day;
|
|
|
|
|
selectedHour = initial.hour;
|
|
|
|
|
selectedMinute = initial.minute;
|
2025-07-28 14:22:07 +08:00
|
|
|
|
|
2025-08-14 18:14:15 +08:00
|
|
|
|
// 初始化天数列表
|
|
|
|
|
days = _getDaysInMonth(selectedYear, selectedMonth);
|
|
|
|
|
|
2025-08-21 16:44:24 +08:00
|
|
|
|
// controllers 初始项索引需在范围内
|
|
|
|
|
yearCtrl = FixedExtentScrollController(
|
|
|
|
|
initialItem: years.indexOf(selectedYear).clamp(0, years.length - 1));
|
2025-08-29 09:52:48 +08:00
|
|
|
|
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 或禁止未来)
|
2025-08-21 16:44:24 +08:00
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
_enforceConstraintsAndUpdateControllers();
|
|
|
|
|
});
|
2025-07-28 14:22:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-08-14 18:14:15 +08:00
|
|
|
|
// 根据年月获取当月天数
|
|
|
|
|
List<int> _getDaysInMonth(int year, int month) {
|
|
|
|
|
final lastDay = DateUtils.getDaysInMonth(year, month);
|
|
|
|
|
return List.generate(lastDay, (i) => i + 1);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 16:44:24 +08:00
|
|
|
|
// 解析 '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(' ');
|
2025-08-29 09:52:48 +08:00
|
|
|
|
final dateParts =
|
|
|
|
|
parts[0].split('-').map((e) => int.parse(e)).toList();
|
2025-08-21 16:44:24 +08:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-14 18:14:15 +08:00
|
|
|
|
// 更新天数列表并调整选中日期
|
2025-08-21 16:44:24 +08:00
|
|
|
|
void _updateDays({bool jumpDay = true}) {
|
2025-08-14 18:14:15 +08:00
|
|
|
|
final newDays = _getDaysInMonth(selectedYear, selectedMonth);
|
|
|
|
|
final isDayValid = selectedDay <= newDays.length;
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
days = newDays;
|
|
|
|
|
if (!isDayValid) {
|
|
|
|
|
selectedDay = newDays.last;
|
2025-08-21 16:44:24 +08:00
|
|
|
|
if (jumpDay) dayCtrl.jumpToItem(selectedDay - 1);
|
2025-08-14 18:14:15 +08:00
|
|
|
|
}
|
|
|
|
|
});
|
2025-07-28 14:22:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 09:52:48 +08:00
|
|
|
|
// 检查并限制时间(模式感知)
|
2025-08-21 16:44:24 +08:00
|
|
|
|
void _enforceConstraintsAndUpdateControllers() {
|
2025-08-29 09:52:48 +08:00
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final isDateOnly = widget.mode == BottomPickerMode.date;
|
|
|
|
|
|
|
|
|
|
final DateTime picked = isDateOnly
|
|
|
|
|
? DateTime(selectedYear, selectedMonth, selectedDay)
|
|
|
|
|
: DateTime(
|
|
|
|
|
selectedYear, selectedMonth, selectedDay, selectedHour, selectedMinute);
|
|
|
|
|
|
|
|
|
|
// 处理 _minTime(如果存在),在 date 模式下只比较日期部分
|
|
|
|
|
if (_minTime != null) {
|
|
|
|
|
final DateTime minRef = isDateOnly
|
|
|
|
|
? DateTime(_minTime!.year, _minTime!.month, _minTime!.day)
|
|
|
|
|
: _minTime!;
|
|
|
|
|
if (picked.isBefore(minRef)) {
|
|
|
|
|
// 把选中项调整为 minRef
|
|
|
|
|
selectedYear = minRef.year;
|
|
|
|
|
selectedMonth = minRef.month;
|
|
|
|
|
selectedDay = minRef.day;
|
|
|
|
|
if (!isDateOnly) {
|
|
|
|
|
selectedHour = minRef.hour;
|
|
|
|
|
selectedMinute = minRef.minute;
|
|
|
|
|
} else {
|
|
|
|
|
selectedHour = 0;
|
|
|
|
|
selectedMinute = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 更新天数及控制器
|
|
|
|
|
_updateDays(jumpDay: false);
|
|
|
|
|
yearCtrl.jumpToItem(years.indexOf(selectedYear));
|
|
|
|
|
monthCtrl.jumpToItem(selectedMonth - 1);
|
|
|
|
|
dayCtrl.jumpToItem(selectedDay - 1);
|
|
|
|
|
if (!isDateOnly) {
|
|
|
|
|
hourCtrl.jumpToItem(selectedHour);
|
|
|
|
|
minuteCtrl.jumpToItem(selectedMinute);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-08-21 16:44:24 +08:00
|
|
|
|
}
|
2025-08-14 18:14:15 +08:00
|
|
|
|
|
2025-08-29 09:52:48 +08:00
|
|
|
|
// 处理禁止选择未来(当 allowFuture == false)
|
2025-08-21 16:44:24 +08:00
|
|
|
|
if (!widget.allowFuture) {
|
2025-08-29 09:52:48 +08:00
|
|
|
|
final DateTime nowRef = isDateOnly
|
|
|
|
|
? DateTime(now.year, now.month, now.day)
|
|
|
|
|
: now;
|
|
|
|
|
if (picked.isAfter(nowRef)) {
|
|
|
|
|
selectedYear = nowRef.year;
|
|
|
|
|
selectedMonth = nowRef.month;
|
|
|
|
|
selectedDay = nowRef.day;
|
|
|
|
|
if (!isDateOnly) {
|
|
|
|
|
selectedHour = nowRef.hour;
|
|
|
|
|
selectedMinute = nowRef.minute;
|
|
|
|
|
} else {
|
|
|
|
|
selectedHour = 0;
|
|
|
|
|
selectedMinute = 0;
|
|
|
|
|
}
|
2025-08-21 16:44:24 +08:00
|
|
|
|
|
|
|
|
|
_updateDays(jumpDay: false);
|
|
|
|
|
yearCtrl.jumpToItem(years.indexOf(selectedYear));
|
|
|
|
|
monthCtrl.jumpToItem(selectedMonth - 1);
|
|
|
|
|
dayCtrl.jumpToItem(selectedDay - 1);
|
2025-08-29 09:52:48 +08:00
|
|
|
|
if (!isDateOnly) {
|
|
|
|
|
hourCtrl.jumpToItem(selectedHour);
|
|
|
|
|
minuteCtrl.jumpToItem(selectedMinute);
|
|
|
|
|
}
|
2025-08-21 16:44:24 +08:00
|
|
|
|
return;
|
|
|
|
|
}
|
2025-07-28 14:22:07 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-14 18:14:15 +08:00
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
yearCtrl.dispose();
|
|
|
|
|
monthCtrl.dispose();
|
|
|
|
|
dayCtrl.dispose();
|
|
|
|
|
hourCtrl.dispose();
|
|
|
|
|
minuteCtrl.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-28 14:22:07 +08:00
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2025-08-29 09:52:48 +08:00
|
|
|
|
final isDateOnly = widget.mode == BottomPickerMode.date;
|
2025-07-28 14:22:07 +08:00
|
|
|
|
return SizedBox(
|
2025-08-29 09:52:48 +08:00
|
|
|
|
height: isDateOnly ? 280 : 330,
|
2025-07-28 14:22:07 +08:00
|
|
|
|
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: () {
|
2025-08-29 09:52:48 +08:00
|
|
|
|
final result = isDateOnly
|
|
|
|
|
? DateTime(selectedYear, selectedMonth, selectedDay)
|
|
|
|
|
: DateTime(
|
2025-07-28 14:22:07 +08:00
|
|
|
|
selectedYear,
|
|
|
|
|
selectedMonth,
|
|
|
|
|
selectedDay,
|
|
|
|
|
selectedHour,
|
|
|
|
|
selectedMinute,
|
|
|
|
|
);
|
|
|
|
|
Navigator.of(context).pop(result);
|
|
|
|
|
},
|
|
|
|
|
child: const Text("确定", style: TextStyle(color: Colors.blue)),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const Divider(height: 1),
|
|
|
|
|
|
2025-08-29 09:52:48 +08:00
|
|
|
|
// 可见的滚轮列(date 模式只显示 年 月 日)
|
2025-07-28 14:22:07 +08:00
|
|
|
|
Expanded(
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
// 年
|
|
|
|
|
_buildPicker(
|
|
|
|
|
controller: yearCtrl,
|
|
|
|
|
items: years.map((e) => e.toString()).toList(),
|
|
|
|
|
onSelected: (idx) {
|
|
|
|
|
setState(() {
|
|
|
|
|
selectedYear = years[idx];
|
2025-08-21 16:44:24 +08:00
|
|
|
|
_updateDays();
|
|
|
|
|
_enforceConstraintsAndUpdateControllers();
|
2025-07-28 14:22:07 +08:00
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 月
|
|
|
|
|
_buildPicker(
|
|
|
|
|
controller: monthCtrl,
|
|
|
|
|
items: months.map((e) => e.toString().padLeft(2, '0')).toList(),
|
|
|
|
|
onSelected: (idx) {
|
|
|
|
|
setState(() {
|
|
|
|
|
selectedMonth = months[idx];
|
2025-08-21 16:44:24 +08:00
|
|
|
|
_updateDays();
|
|
|
|
|
_enforceConstraintsAndUpdateControllers();
|
2025-07-28 14:22:07 +08:00
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 日
|
|
|
|
|
_buildPicker(
|
|
|
|
|
controller: dayCtrl,
|
|
|
|
|
items: days.map((e) => e.toString().padLeft(2, '0')).toList(),
|
|
|
|
|
onSelected: (idx) {
|
|
|
|
|
setState(() {
|
2025-08-21 16:44:24 +08:00
|
|
|
|
final safeIdx = idx.clamp(0, days.length - 1);
|
|
|
|
|
selectedDay = days[safeIdx];
|
|
|
|
|
_enforceConstraintsAndUpdateControllers();
|
2025-07-28 14:22:07 +08:00
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
|
2025-08-29 09:52:48 +08:00
|
|
|
|
// 若不是 dateOnly,则显示时分两列
|
|
|
|
|
if (!isDateOnly)
|
|
|
|
|
_buildPicker(
|
|
|
|
|
controller: hourCtrl,
|
|
|
|
|
items: hours.map((e) => e.toString().padLeft(2, '0')).toList(),
|
|
|
|
|
onSelected: (idx) {
|
|
|
|
|
setState(() {
|
|
|
|
|
selectedHour = hours[idx];
|
|
|
|
|
_enforceConstraintsAndUpdateControllers();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
if (!isDateOnly)
|
|
|
|
|
_buildPicker(
|
|
|
|
|
controller: minuteCtrl,
|
|
|
|
|
items: minutes.map((e) => e.toString().padLeft(2, '0')).toList(),
|
|
|
|
|
onSelected: (idx) {
|
|
|
|
|
setState(() {
|
|
|
|
|
selectedMinute = minutes[idx];
|
|
|
|
|
_enforceConstraintsAndUpdateControllers();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
2025-07-28 14:22:07 +08:00
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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]));
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-21 16:44:24 +08:00
|
|
|
|
}
|