feat(client): 日期范围改锚定下拉——起止并排双滚轮 + 可键入,不再冻结窗口
- 新增 showDateRangeDropdown:锚定触发控件下方(透明遮罩点外关闭,形态同 DsMenu 下拉),起始/结束两列并排,各带可输入框(键入/回车/失焦归一 yyyy-MM-dd)与滚轮,输入↔滚轮双向联动;共用底栏显示当前范围 + 确定, 起止倒置自动交换;窄屏回落既有两步滚轮弹窗 - WheelDatePanel 加 showFooter/onChanged(滚动即回调),供范围面板复用 - 出入库列表 6 处调用切换:工具栏时间 chip「自定义…」、桌面详搜弹窗、 窄屏详搜 sheet 的日期区间字段均锚定在各自控件下方 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -198,8 +198,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
n.setDetail(const {});
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final range = await showWheelDateRange(context, initial: _dateRange);
|
||||
Future<void> _pickDateRange(BuildContext anchor) async {
|
||||
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
|
||||
if (range != null) {
|
||||
setState(() {
|
||||
_dateRange = range;
|
||||
@@ -210,9 +210,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
}
|
||||
|
||||
/// 入库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。
|
||||
void _setDatePreset(String preset) {
|
||||
void _setDatePreset(BuildContext anchor, String preset) {
|
||||
if (preset == 'custom') {
|
||||
_pickDateRange();
|
||||
_pickDateRange(anchor);
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
@@ -245,26 +245,29 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
ref.read(stockInListProvider.notifier).setDateRange(_startDate, _endDate);
|
||||
}
|
||||
|
||||
Widget _dateMenu(BuildContext ctx) => PopupMenuButton<String>(
|
||||
onSelected: _setDatePreset,
|
||||
offset: const Offset(0, 40),
|
||||
color: ctx.tokens.surface,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: ctx.tokens.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
Widget _dateMenu(BuildContext ctx) => Builder(
|
||||
// anchorCtx = chip 自身位置:自定义范围下拉锚定在 chip 下方
|
||||
builder: (anchorCtx) => PopupMenuButton<String>(
|
||||
onSelected: (v) => _setDatePreset(anchorCtx, v),
|
||||
offset: const Offset(0, 40),
|
||||
color: ctx.tokens.surface,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: ctx.tokens.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')),
|
||||
PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')),
|
||||
PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')),
|
||||
PopupMenuItem(value: 'month', height: 38, child: Text('本月')),
|
||||
PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')),
|
||||
],
|
||||
child: DsChip(
|
||||
label: '入库时间',
|
||||
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
|
||||
onClear: () => _setDatePreset(anchorCtx, 'all')),
|
||||
),
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')),
|
||||
PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')),
|
||||
PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')),
|
||||
PopupMenuItem(value: 'month', height: 38, child: Text('本月')),
|
||||
PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')),
|
||||
],
|
||||
child: DsChip(
|
||||
label: '入库时间',
|
||||
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
|
||||
onClear: () => _setDatePreset('all')),
|
||||
);
|
||||
|
||||
Future<void> _openAdvSearch() async {
|
||||
@@ -1649,8 +1652,8 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> {
|
||||
Navigator.of(context).pop(_AdvResult(d, _status, _range));
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final range = await showWheelDateRange(context, initial: _range);
|
||||
Future<void> _pickDate(BuildContext anchor) async {
|
||||
final range = await showDateRangeDropdown(anchor, initial: _range);
|
||||
if (range != null) setState(() => _range = range);
|
||||
}
|
||||
|
||||
@@ -1930,33 +1933,35 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> {
|
||||
final label = has
|
||||
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
|
||||
: '全部时间';
|
||||
return InkWell(
|
||||
onTap: _pickDate,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody, color: has ? t.text : t.faint)),
|
||||
),
|
||||
if (has)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _range = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
);
|
||||
return Builder(
|
||||
builder: (anchorCtx) => InkWell(
|
||||
onTap: () => _pickDate(anchorCtx),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: has ? t.text : t.faint)),
|
||||
),
|
||||
if (has)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _range = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2055,8 +2060,8 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
|
||||
Navigator.of(context).pop(_AdvResult(d, _status, _range));
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final range = await showWheelDateRange(context, initial: _range);
|
||||
Future<void> _pickDate(BuildContext anchor) async {
|
||||
final range = await showDateRangeDropdown(anchor, initial: _range);
|
||||
if (range != null) setState(() => _range = range);
|
||||
}
|
||||
|
||||
@@ -2171,37 +2176,39 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
|
||||
final statusSelIdx = _statusCodes.indexOf(_status);
|
||||
|
||||
final dateHas = _range != null;
|
||||
final dateField = InkWell(
|
||||
onTap: _pickDate,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateHas
|
||||
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
|
||||
: '全部时间',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint),
|
||||
),
|
||||
),
|
||||
if (dateHas)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _range = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
);
|
||||
final dateField = Builder(
|
||||
builder: (anchorCtx) => InkWell(
|
||||
onTap: () => _pickDate(anchorCtx),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateHas
|
||||
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
|
||||
: '全部时间',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: dateHas ? t.text : t.faint),
|
||||
),
|
||||
),
|
||||
if (dateHas)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _range = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
));
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1713,8 +1713,8 @@ class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> {
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final range = await showWheelDateRange(context, initial: _dateRange);
|
||||
Future<void> _pickDate(BuildContext anchor) async {
|
||||
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
|
||||
if (range != null) setState(() => _dateRange = range);
|
||||
}
|
||||
|
||||
@@ -1838,38 +1838,39 @@ class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> {
|
||||
}
|
||||
|
||||
final dateHas = _dateRange != null;
|
||||
Widget dateField() => InkWell(
|
||||
onTap: _pickDate,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
Widget dateField() => Builder(
|
||||
builder: (anchorCtx) => InkWell(
|
||||
onTap: () => _pickDate(anchorCtx),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateHas
|
||||
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
|
||||
: '全部时间',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: dateHas ? t.text : t.faint),
|
||||
child: Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateHas
|
||||
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
|
||||
: '全部时间',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: dateHas ? t.text : t.faint),
|
||||
),
|
||||
),
|
||||
if (dateHas)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _dateRange = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
if (dateHas)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _dateRange = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
);
|
||||
));
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: t.surface,
|
||||
@@ -2135,8 +2136,8 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final range = await showWheelDateRange(context, initial: _dateRange);
|
||||
Future<void> _pickDate(BuildContext anchor) async {
|
||||
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
|
||||
if (range != null) setState(() => _dateRange = range);
|
||||
}
|
||||
|
||||
@@ -2225,37 +2226,39 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
|
||||
final statusSelIdx = _statusCodes.indexOf(_status);
|
||||
|
||||
final dateHas = _dateRange != null;
|
||||
final dateField = InkWell(
|
||||
onTap: _pickDate,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateHas
|
||||
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
|
||||
: '全部时间',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint),
|
||||
),
|
||||
),
|
||||
if (dateHas)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _dateRange = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
);
|
||||
final dateField = Builder(
|
||||
builder: (anchorCtx) => InkWell(
|
||||
onTap: () => _pickDate(anchorCtx),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateHas
|
||||
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
|
||||
: '全部时间',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: dateHas ? t.text : t.faint),
|
||||
),
|
||||
),
|
||||
if (dateHas)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _dateRange = null),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.calendar, size: 16, color: t.faint),
|
||||
]),
|
||||
),
|
||||
));
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/responsive/responsive.dart';
|
||||
import '../core/theme/context_tokens.dart';
|
||||
import '../core/theme/app_tokens.dart';
|
||||
import '../core/theme/app_dims.g.dart';
|
||||
import '../core/utils/date_util.dart';
|
||||
import '../core/utils/dialog_util.dart';
|
||||
import '../core/theme/app_fonts.dart';
|
||||
|
||||
@@ -20,11 +24,19 @@ class WheelDatePanel extends StatefulWidget {
|
||||
/// 可选标题(范围选择用「起始/结束日期」区分);空则不渲染(对齐原型无标题)。
|
||||
final String title;
|
||||
|
||||
/// 滚动即回调当前值(范围下拉的联动输入框用);null 不回调。
|
||||
final ValueChanged<DateTime>? onChanged;
|
||||
|
||||
/// false 隐藏「今天/确定」底栏(范围下拉共用一个底栏时用),onCommit 不再触发。
|
||||
final bool showFooter;
|
||||
|
||||
const WheelDatePanel({
|
||||
super.key,
|
||||
required this.initial,
|
||||
required this.onCommit,
|
||||
this.title = '',
|
||||
this.onChanged,
|
||||
this.showFooter = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -78,6 +90,7 @@ class _WheelDatePanelState extends State<WheelDatePanel> {
|
||||
_dCtrl.jumpToItem(_dIdx);
|
||||
}
|
||||
setState(() {});
|
||||
widget.onChanged?.call(DateTime(_year, _month, _dIdx + 1));
|
||||
}
|
||||
|
||||
void _goToday() {
|
||||
@@ -157,55 +170,59 @@ class _WheelDatePanelState extends State<WheelDatePanel> {
|
||||
_mIdx = i;
|
||||
_onYearOrMonth();
|
||||
}),
|
||||
_col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx,
|
||||
(i) => setState(() => _dIdx = i)),
|
||||
_col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx, (i) {
|
||||
setState(() => _dIdx = i);
|
||||
widget.onChanged
|
||||
?.call(DateTime(_year, _month, _dIdx + 1));
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底栏:今天(左) / 确定(右,无取消,点外部取消)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _goToday,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
child: Text('今天',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
widget.onCommit(DateTime(_year, _month, _dIdx + 1)),
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: t.primary,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
if (widget.showFooter)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _goToday,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
child: Text('今天',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
child: Text('确定',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.onPrimary,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
widget.onCommit(DateTime(_year, _month, _dIdx + 1)),
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: t.primary,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
),
|
||||
child: Text('确定',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.onPrimary,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -285,3 +302,320 @@ Future<DateTimeRange?> showWheelDateRange(
|
||||
? DateTimeRange(start: end, end: start)
|
||||
: DateTimeRange(start: start, end: end);
|
||||
}
|
||||
|
||||
// ═══════════════ 日期范围下拉(桌面)═══════════════
|
||||
// 锚定触发控件下方的双列面板:起始 | 结束 并排各一组「可输入框 + 滚轮」,
|
||||
// 透明遮罩点外关闭(不冻结窗口,形态同 DsMenu 下拉);共用一个「确定」底栏。
|
||||
// 窄屏(无悬停、面板超宽)回落到居中滚轮弹窗(showWheelDateRange)。
|
||||
|
||||
/// 打开日期范围下拉,锚定在 [anchorContext](触发控件自身的 context)下方。
|
||||
/// 确定返回 DateTimeRange(起止倒置自动交换);点外部关闭返回 null(不变更)。
|
||||
Future<DateTimeRange?> showDateRangeDropdown(
|
||||
BuildContext anchorContext, {
|
||||
DateTimeRange? initial,
|
||||
}) {
|
||||
if (anchorContext.isMobile) {
|
||||
// 窄屏:双列 530px 放不下,保持既有两步滚轮弹窗
|
||||
return showWheelDateRange(anchorContext, initial: initial);
|
||||
}
|
||||
final overlay = Navigator.of(anchorContext)
|
||||
.overlay!
|
||||
.context
|
||||
.findRenderObject() as RenderBox;
|
||||
final box = anchorContext.findRenderObject() as RenderBox?;
|
||||
final rect = box == null
|
||||
? Rect.zero
|
||||
: box.localToGlobal(Offset.zero, ancestor: overlay) & box.size;
|
||||
return Navigator.of(anchorContext).push<DateTimeRange>(
|
||||
_RangeDropdownRoute(anchorRect: rect, initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
class _RangeDropdownRoute extends PopupRoute<DateTimeRange> {
|
||||
final Rect anchorRect;
|
||||
final DateTimeRange? initial;
|
||||
_RangeDropdownRoute({required this.anchorRect, this.initial});
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.transparent;
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
@override
|
||||
String? get barrierLabel => 'date-range';
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
return CustomSingleChildLayout(
|
||||
delegate: _RangeDropdownLayout(anchorRect),
|
||||
child: _DateRangePanel(
|
||||
initial: initial,
|
||||
onCommit: (r) => Navigator.of(context).pop(r),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RangeDropdownLayout extends SingleChildLayoutDelegate {
|
||||
final Rect anchor;
|
||||
_RangeDropdownLayout(this.anchor);
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
|
||||
BoxConstraints(
|
||||
maxWidth: math.max(0, constraints.maxWidth - 16),
|
||||
maxHeight: math.max(0, constraints.maxHeight - 16),
|
||||
);
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final left = anchor.left
|
||||
.clamp(8.0, math.max(8.0, size.width - childSize.width - 8))
|
||||
.toDouble();
|
||||
final below = anchor.bottom + 6;
|
||||
final above = anchor.top - childSize.height - 6;
|
||||
final top = (below + childSize.height <= size.height - 8 || above < 8)
|
||||
? below
|
||||
: above;
|
||||
return Offset(left, top.clamp(8.0, math.max(8.0, size.height - 8)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_RangeDropdownLayout old) => old.anchor != anchor;
|
||||
}
|
||||
|
||||
class _DateRangePanel extends StatefulWidget {
|
||||
final DateTimeRange? initial;
|
||||
final ValueChanged<DateTimeRange> onCommit;
|
||||
const _DateRangePanel({this.initial, required this.onCommit});
|
||||
|
||||
@override
|
||||
State<_DateRangePanel> createState() => _DateRangePanelState();
|
||||
}
|
||||
|
||||
class _DateRangePanelState extends State<_DateRangePanel> {
|
||||
late DateTime _start = widget.initial?.start ?? DateTime.now();
|
||||
late DateTime _end = widget.initial?.end ?? DateTime.now();
|
||||
|
||||
void _commit() {
|
||||
final r = _end.isBefore(_start)
|
||||
? DateTimeRange(start: _end, end: _start)
|
||||
: DateTimeRange(start: _start, end: _end);
|
||||
widget.onCommit(r);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return Material(
|
||||
color: t.surface,
|
||||
elevation: 10,
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
child: Container(
|
||||
width: 552,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _RangeSide(
|
||||
title: '起始日期',
|
||||
value: _start,
|
||||
onChanged: (d) => setState(() => _start = d),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _RangeSide(
|
||||
title: '结束日期',
|
||||
value: _end,
|
||||
onChanged: (d) => setState(() => _end = d),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 底栏(共用):范围文案(左)/ 确定(右);点外部即取消
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${formatYmd(_start)} ~ ${formatYmd(_end)}',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.muted,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _commit,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: t.primary,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
),
|
||||
child: Text('确定',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.onPrimary,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 范围面板单侧:标题 + 可输入框(键入即联动滚轮)+ 滚轮(滚动即联动输入框)。
|
||||
class _RangeSide extends StatefulWidget {
|
||||
final String title;
|
||||
final DateTime value;
|
||||
final ValueChanged<DateTime> onChanged;
|
||||
const _RangeSide(
|
||||
{required this.title, required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<_RangeSide> createState() => _RangeSideState();
|
||||
}
|
||||
|
||||
class _RangeSideState extends State<_RangeSide> {
|
||||
late final TextEditingController _ctrl =
|
||||
TextEditingController(text: formatYmd(widget.value));
|
||||
final FocusNode _focus = FocusNode();
|
||||
// 键入归一后的滚轮锚定值:仅键入产生完整有效日期时更新(驱动滚轮重建吸附)
|
||||
late DateTime _wheelAnchor = widget.value;
|
||||
bool _syncing = false; // 滚轮回写输入框时跳过 onChanged 循环
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(() {
|
||||
if (!_focus.hasFocus) {
|
||||
// 失焦归一显示(2024 → 2024-01-01)并吸附滚轮
|
||||
final n = normalizeYmd(_ctrl.text);
|
||||
if (n != null) {
|
||||
_syncing = true;
|
||||
_ctrl.text = n;
|
||||
_syncing = false;
|
||||
_applyTyped(n);
|
||||
} else {
|
||||
_syncing = true;
|
||||
_ctrl.text = formatYmd(widget.value);
|
||||
_syncing = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _applyTyped(String ymd) {
|
||||
final d = parseYmd(ymd);
|
||||
if (d == null) return;
|
||||
widget.onChanged(d);
|
||||
if (d != _wheelAnchor) setState(() => _wheelAnchor = d);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||
child: Text(widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.muted)),
|
||||
),
|
||||
Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: _focus.hasFocus ? t.primary : t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focus,
|
||||
keyboardType: TextInputType.datetime,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: t.text,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
hintText: '年-月-日',
|
||||
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
|
||||
),
|
||||
onChanged: (v) {
|
||||
if (_syncing) return;
|
||||
// 键入完整日期(含 8 位纯数字)即联动滚轮;不完整先不动
|
||||
final n = normalizeYmd(v);
|
||||
if (n != null && v.trim().length >= 8) _applyTyped(n);
|
||||
},
|
||||
onSubmitted: (v) {
|
||||
final n = normalizeYmd(v);
|
||||
if (n != null) {
|
||||
_syncing = true;
|
||||
_ctrl.text = n;
|
||||
_syncing = false;
|
||||
_applyTyped(n);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
WheelDatePanel(
|
||||
key: ValueKey('${widget.title}-${formatYmd(_wheelAnchor)}'),
|
||||
initial: _wheelAnchor,
|
||||
showFooter: false,
|
||||
onCommit: (_) {},
|
||||
onChanged: (d) {
|
||||
widget.onChanged(d);
|
||||
_syncing = true;
|
||||
_ctrl.text = formatYmd(d);
|
||||
_syncing = false;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user