feat: 实现日历提醒 in-app fallback 机制及通知服务重构
This commit is contained in:
@@ -1,17 +1,23 @@
|
||||
enum ReminderAction {
|
||||
cancel('cancel'),
|
||||
snooze10m('snooze_10m'),
|
||||
timeout30s('timeout_30s'),
|
||||
autoArchive('auto_archive');
|
||||
archive('archive'),
|
||||
snooze10m('snooze10m');
|
||||
|
||||
const ReminderAction(this.value);
|
||||
|
||||
final String value;
|
||||
|
||||
static ReminderAction fromValue(String raw) {
|
||||
return ReminderAction.values.firstWhere(
|
||||
(item) => item.value == raw,
|
||||
orElse: () => ReminderAction.timeout30s,
|
||||
);
|
||||
switch (raw) {
|
||||
case 'archive':
|
||||
case 'cancel':
|
||||
case 'auto_archive':
|
||||
return ReminderAction.archive;
|
||||
case 'snooze10m':
|
||||
case 'snooze_10m':
|
||||
case 'timeout_30s':
|
||||
return ReminderAction.snooze10m;
|
||||
default:
|
||||
throw ArgumentError.value(raw, 'raw', 'Unsupported reminder action');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class ReminderPayload {
|
||||
final String? color;
|
||||
final ReminderPayloadMode mode;
|
||||
final List<String> aggregateIds;
|
||||
final int? fireTimeBucket;
|
||||
final int version;
|
||||
|
||||
const ReminderPayload({
|
||||
@@ -22,6 +23,7 @@ class ReminderPayload {
|
||||
this.color,
|
||||
this.mode = ReminderPayloadMode.single,
|
||||
this.aggregateIds = const [],
|
||||
this.fireTimeBucket,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
@@ -36,6 +38,7 @@ class ReminderPayload {
|
||||
String? color,
|
||||
ReminderPayloadMode? mode,
|
||||
List<String>? aggregateIds,
|
||||
int? fireTimeBucket,
|
||||
int? version,
|
||||
}) {
|
||||
return ReminderPayload(
|
||||
@@ -49,6 +52,7 @@ class ReminderPayload {
|
||||
color: color ?? this.color,
|
||||
mode: mode ?? this.mode,
|
||||
aggregateIds: aggregateIds ?? this.aggregateIds,
|
||||
fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
|
||||
version: version ?? this.version,
|
||||
);
|
||||
}
|
||||
@@ -65,6 +69,7 @@ class ReminderPayload {
|
||||
'color': color,
|
||||
'mode': mode.value,
|
||||
'aggregateIds': aggregateIds,
|
||||
'fireTimeBucket': fireTimeBucket,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
@@ -104,6 +109,7 @@ class ReminderPayload {
|
||||
color: json['color'] as String?,
|
||||
mode: mode,
|
||||
aggregateIds: aggregateIds,
|
||||
fireTimeBucket: json['fireTimeBucket'] as int?,
|
||||
version: (json['version'] as int?) ?? 1,
|
||||
);
|
||||
}
|
||||
@@ -124,6 +130,7 @@ class ReminderPayload {
|
||||
other.color == color &&
|
||||
other.mode == mode &&
|
||||
_listEquals(other.aggregateIds, aggregateIds) &&
|
||||
other.fireTimeBucket == fireTimeBucket &&
|
||||
other.version == version;
|
||||
}
|
||||
|
||||
@@ -140,6 +147,7 @@ class ReminderPayload {
|
||||
color,
|
||||
mode,
|
||||
Object.hashAll(aggregateIds),
|
||||
fireTimeBucket,
|
||||
version,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
typedef SetStringListFn = Future<bool> Function(String key, List<String> value);
|
||||
|
||||
class ReminderActionDedupeStore {
|
||||
static const String _key = 'calendar_reminder_action_dedupe_v1';
|
||||
static const int _maxEntries = 512;
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
final SetStringListFn _setStringList;
|
||||
Future<void> _queue = Future<void>.value();
|
||||
|
||||
ReminderActionDedupeStore(
|
||||
SharedPreferences prefs, {
|
||||
SetStringListFn? setStringList,
|
||||
}) : _prefs = prefs,
|
||||
_setStringList = setStringList ?? prefs.setStringList;
|
||||
|
||||
Future<bool> markIfNew(String actionExecutionId) async {
|
||||
final completer = Completer<bool>();
|
||||
_queue = _queue
|
||||
.then((_) async {
|
||||
completer.complete(await _markIfNewInternal(actionExecutionId));
|
||||
})
|
||||
.catchError((_) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<bool> _markIfNewInternal(String actionExecutionId) async {
|
||||
final current = List<String>.from(
|
||||
_prefs.getStringList(_key) ?? const <String>[],
|
||||
);
|
||||
if (current.contains(actionExecutionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
current.add(actionExecutionId);
|
||||
if (current.length > _maxEntries) {
|
||||
current.removeRange(0, current.length - _maxEntries);
|
||||
}
|
||||
|
||||
final saved = await _setStringList(_key, current);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -27,19 +27,20 @@ class ReminderActionExecutor {
|
||||
required ReminderPayload payload,
|
||||
}) async {
|
||||
final ids = payload.mode == ReminderPayloadMode.aggregate
|
||||
? payload.aggregateIds
|
||||
? (payload.aggregateIds.isNotEmpty
|
||||
? payload.aggregateIds
|
||||
: <String>[payload.eventId])
|
||||
: <String>[payload.eventId];
|
||||
|
||||
if (action == ReminderAction.cancel) {
|
||||
if (action == ReminderAction.archive) {
|
||||
for (final id in ids) {
|
||||
await _notificationService.cancelEventReminder(id);
|
||||
await _archiveEvent(id, ReminderAction.cancel);
|
||||
await _archiveEvent(id, ReminderAction.archive);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == ReminderAction.snooze10m ||
|
||||
action == ReminderAction.timeout30s) {
|
||||
if (action == ReminderAction.snooze10m) {
|
||||
for (final id in ids) {
|
||||
await _snoozeEvent(id);
|
||||
}
|
||||
@@ -50,7 +51,6 @@ class ReminderActionExecutor {
|
||||
final pending = await _outboxStore.listPending();
|
||||
for (final item in pending) {
|
||||
if (item.targetStatus != 'archived') {
|
||||
await _outboxStore.markDone(item.opId);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -71,14 +71,14 @@ class ReminderActionExecutor {
|
||||
final endAt = event.endAt;
|
||||
if (endAt != null && !now.isBefore(endAt)) {
|
||||
await _notificationService.cancelEventReminder(eventId);
|
||||
await _archiveEvent(eventId, ReminderAction.autoArchive);
|
||||
await _archiveEvent(eventId, ReminderAction.archive);
|
||||
return;
|
||||
}
|
||||
|
||||
final nextAt = now.add(const Duration(minutes: 10));
|
||||
if (endAt != null && !nextAt.isBefore(endAt)) {
|
||||
await _notificationService.cancelEventReminder(eventId);
|
||||
await _archiveEvent(eventId, ReminderAction.autoArchive);
|
||||
await _archiveEvent(eventId, ReminderAction.archive);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
typedef ReminderColdStartReplayTask = Future<void> Function();
|
||||
typedef ReminderColdStartTaskErrorHandler =
|
||||
void Function(Object error, StackTrace stackTrace);
|
||||
|
||||
class ReminderColdStartQueue {
|
||||
final Queue<ReminderColdStartReplayTask> _tasks =
|
||||
Queue<ReminderColdStartReplayTask>();
|
||||
final ReminderColdStartTaskErrorHandler? _onTaskError;
|
||||
Future<void>? _inFlightReplay;
|
||||
|
||||
ReminderColdStartQueue({ReminderColdStartTaskErrorHandler? onTaskError})
|
||||
: _onTaskError = onTaskError;
|
||||
|
||||
void enqueue(ReminderColdStartReplayTask task) {
|
||||
_tasks.add(task);
|
||||
}
|
||||
|
||||
Future<void> replay() {
|
||||
final inFlightReplay = _inFlightReplay;
|
||||
if (inFlightReplay != null) {
|
||||
return inFlightReplay;
|
||||
}
|
||||
|
||||
final replayCompleter = Completer<void>();
|
||||
final replayFuture = replayCompleter.future;
|
||||
_inFlightReplay = replayFuture;
|
||||
|
||||
scheduleMicrotask(() async {
|
||||
try {
|
||||
await _replayInternal();
|
||||
replayCompleter.complete();
|
||||
} catch (error, stackTrace) {
|
||||
replayCompleter.completeError(error, stackTrace);
|
||||
} finally {
|
||||
if (identical(_inFlightReplay, replayFuture)) {
|
||||
_inFlightReplay = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return replayFuture;
|
||||
}
|
||||
|
||||
Future<void> _replayInternal() async {
|
||||
while (_tasks.isNotEmpty) {
|
||||
final task = _tasks.removeFirst();
|
||||
try {
|
||||
await task();
|
||||
} catch (error, stackTrace) {
|
||||
_onTaskError?.call(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../models/reminder_action.dart';
|
||||
import '../models/reminder_payload.dart';
|
||||
import '../reminder_action_executor.dart';
|
||||
import 'reminder_presentation_coordinator.dart';
|
||||
import 'widgets/reminder_action_sheet.dart';
|
||||
|
||||
class ReminderForegroundPresenter {
|
||||
final GlobalKey<NavigatorState> _navigatorKey;
|
||||
final ReminderActionExecutor _executor;
|
||||
final ReminderPresentationCoordinator _coordinator;
|
||||
bool _isPresenting = false;
|
||||
|
||||
ReminderForegroundPresenter({
|
||||
required GlobalKey<NavigatorState> navigatorKey,
|
||||
required ReminderActionExecutor executor,
|
||||
ReminderPresentationCoordinator? coordinator,
|
||||
}) : _navigatorKey = navigatorKey,
|
||||
_executor = executor,
|
||||
_coordinator = coordinator ?? ReminderPresentationCoordinator();
|
||||
|
||||
Future<void> present(ReminderPayload payload) async {
|
||||
final context = _navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final lifecycleState = WidgetsBinding.instance.lifecycleState;
|
||||
final isAppActive = lifecycleState == AppLifecycleState.resumed;
|
||||
final shouldPresent = _coordinator.shouldPresent(
|
||||
eventId: payload.eventId,
|
||||
isAppActive: isAppActive,
|
||||
);
|
||||
if (!shouldPresent || _isPresenting) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isPresenting = true;
|
||||
try {
|
||||
final action = await showModalBottomSheet<ReminderAction>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: ReminderActionSheet(
|
||||
onSnooze: () {
|
||||
Navigator.of(sheetContext).pop(ReminderAction.snooze10m);
|
||||
},
|
||||
onArchive: () {
|
||||
Navigator.of(sheetContext).pop(ReminderAction.archive);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (action == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _executor.handleAction(action: action, payload: payload);
|
||||
} finally {
|
||||
_isPresenting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
typedef ReminderPresentationNow = DateTime Function();
|
||||
|
||||
class ReminderPresentationCoordinator {
|
||||
final Duration _dedupeWindow;
|
||||
final ReminderPresentationNow _now;
|
||||
final Map<String, DateTime> _lastPresentedAtByEventId = <String, DateTime>{};
|
||||
|
||||
ReminderPresentationCoordinator({
|
||||
Duration dedupeWindow = const Duration(seconds: 30),
|
||||
ReminderPresentationNow? now,
|
||||
}) : _dedupeWindow = dedupeWindow,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
bool shouldPresent({required String eventId, required bool isAppActive}) {
|
||||
if (!isAppActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final currentTime = _now();
|
||||
final lastPresentedAt = _lastPresentedAtByEventId[eventId];
|
||||
if (lastPresentedAt != null &&
|
||||
currentTime.difference(lastPresentedAt) < _dedupeWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastPresentedAtByEventId[eventId] = currentTime;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../../shared/widgets/app_button.dart';
|
||||
|
||||
class ReminderActionSheet extends StatelessWidget {
|
||||
const ReminderActionSheet({
|
||||
super.key,
|
||||
required this.onSnooze,
|
||||
required this.onArchive,
|
||||
});
|
||||
|
||||
final VoidCallback onSnooze;
|
||||
final VoidCallback onArchive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'提醒操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: AppColors.slate900),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
text: '稍后提醒',
|
||||
isOutlined: true,
|
||||
onPressed: onSnooze,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: AppButton(text: '归档', onPressed: onArchive),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/back_title_page_header.dart';
|
||||
import '../../../../shared/widgets/detail_header_action_menu.dart';
|
||||
import '../../../../shared/widgets/destructive_action_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/services/calendar_service.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
import '../utils/event_color_resolver.dart';
|
||||
|
||||
enum _CalendarHeaderAction { edit, delete, share }
|
||||
enum _CalendarHeaderAction { edit, delete, share, archive }
|
||||
|
||||
class CalendarEventDetailScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
@@ -190,6 +192,17 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (event.status != ScheduleStatus.archived && event.canEdit) {
|
||||
final isExpired = _isEventExpired(event);
|
||||
items.add(
|
||||
DetailHeaderActionItem<_CalendarHeaderAction>(
|
||||
value: _CalendarHeaderAction.archive,
|
||||
label: '归档',
|
||||
icon: LucideIcons.archive,
|
||||
enabled: !isExpired,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return DetailHeaderActionMenu<_CalendarHeaderAction>(
|
||||
items: items,
|
||||
@@ -197,6 +210,14 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isEventExpired(ScheduleItemModel event) {
|
||||
final now = DateTime.now();
|
||||
if (event.endAt != null) {
|
||||
return event.endAt!.isBefore(now);
|
||||
}
|
||||
return event.startAt.isBefore(now);
|
||||
}
|
||||
|
||||
void _handleHeaderAction(
|
||||
_CalendarHeaderAction action,
|
||||
ScheduleItemModel event,
|
||||
@@ -213,6 +234,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
case _CalendarHeaderAction.share:
|
||||
context.push(AppRoutes.calendarEventShare(event.id));
|
||||
return;
|
||||
case _CalendarHeaderAction.archive:
|
||||
_archiveEvent();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +484,29 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
Future<void> _archiveEvent() async {
|
||||
final confirmed = await showDestructiveActionSheet(
|
||||
context,
|
||||
title: '归档日程',
|
||||
message: '归档后此日程将标记为过期,确定要归档吗?',
|
||||
confirmText: '确认归档',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sl<CalendarService>().archiveEvent(widget.eventId);
|
||||
await _loadEvent();
|
||||
if (mounted) {
|
||||
Toast.show(context, '已归档', type: ToastType.success);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Toast.show(context, '归档失败', type: ToastType.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRangeLabel(DateTime startAt, DateTime? endAt) {
|
||||
final dateLabel =
|
||||
'${startAt.month}月${startAt.day}日 ${_getWeekday(startAt.weekday)}';
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/notifications/local_notification_service.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/app_selection_sheet.dart';
|
||||
import '../../../../shared/widgets/app_sheet_input_field.dart';
|
||||
import '../../../../shared/widgets/back_title_page_header.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
@@ -107,12 +108,21 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
event.metadata?.reminderMinutes,
|
||||
);
|
||||
} else {
|
||||
final now =
|
||||
widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5);
|
||||
_startDate = now;
|
||||
_startTime = now;
|
||||
_endDate = now;
|
||||
_endTime = now.add(const Duration(hours: 1));
|
||||
final now = DateTime.now();
|
||||
final initial = widget.initialDate;
|
||||
final rounded = _roundToNearestMinute(now, 5);
|
||||
_startDate = initial != null
|
||||
? DateTime(
|
||||
initial.year,
|
||||
initial.month,
|
||||
initial.day,
|
||||
rounded.hour,
|
||||
rounded.minute,
|
||||
)
|
||||
: rounded;
|
||||
_startTime = _startDate;
|
||||
_endDate = _startDate;
|
||||
_endTime = _startDate.add(const Duration(hours: 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,15 +149,19 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.pageMode) {
|
||||
return Container(
|
||||
color: AppColors.background,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPageHeader(),
|
||||
_buildTabBar(),
|
||||
Expanded(child: _buildTabContent()),
|
||||
],
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Container(
|
||||
color: AppColors.background,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPageHeader(),
|
||||
_buildTabBar(),
|
||||
Expanded(child: _buildTabContent()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -331,12 +345,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTextField(
|
||||
'标题',
|
||||
_titleController,
|
||||
'请输入日程标题',
|
||||
autofocus: !_isEditing,
|
||||
),
|
||||
_buildTextField('标题', _titleController, '请输入日程标题'),
|
||||
const SizedBox(height: 20),
|
||||
_buildDateTimePicker('开始', _startDate, _startTime, (date, time) {
|
||||
setState(() {
|
||||
@@ -580,7 +589,6 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
}
|
||||
|
||||
Widget _buildReminderPicker() {
|
||||
final options = _buildReminderOptions();
|
||||
String labelOf(int? value) {
|
||||
if (value == null) {
|
||||
return '无提醒';
|
||||
@@ -603,37 +611,51 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int?>(
|
||||
value: _reminderMinutes,
|
||||
isExpanded: true,
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final options = _buildReminderOptions();
|
||||
final selected = await showAppSelectionSheet<int?>(
|
||||
context,
|
||||
title: '选择提醒时间',
|
||||
items: options
|
||||
.map(
|
||||
(value) => DropdownMenuItem<int?>(
|
||||
value: value,
|
||||
child: Text(
|
||||
labelOf(value),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map((v) => AppSelectionItem(value: v, label: labelOf(v)))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_reminderMinutes = value;
|
||||
});
|
||||
},
|
||||
selectedValue: _reminderMinutes,
|
||||
);
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
_reminderMinutes = selected;
|
||||
});
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.slate50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
labelOf(_reminderMinutes),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -54,7 +54,7 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
|
||||
if (_selectedYear == minDate.year &&
|
||||
_selectedMonth == minDate.month &&
|
||||
_selectedDay == minDate.day) {
|
||||
return _allHours.where((h) => h > minDate.hour).toList();
|
||||
return _allHours.where((h) => h >= minDate.hour).toList();
|
||||
}
|
||||
return _allHours;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
|
||||
_selectedMonth == minDate.month &&
|
||||
_selectedDay == minDate.day &&
|
||||
_selectedHour == minDate.hour) {
|
||||
return _allMinutes.where((m) => m > minDate.minute).toList();
|
||||
return _allMinutes.where((m) => m >= minDate.minute).toList();
|
||||
}
|
||||
return _allMinutes;
|
||||
}
|
||||
@@ -100,6 +100,12 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
|
||||
_hourController = FixedExtentScrollController(
|
||||
initialItem: _filteredHours.indexOf(_selectedHour),
|
||||
);
|
||||
|
||||
if (_filteredMinutes.isEmpty) {
|
||||
_selectedMinute = 0;
|
||||
} else if (!_filteredMinutes.contains(_selectedMinute)) {
|
||||
_selectedMinute = _filteredMinutes.first;
|
||||
}
|
||||
_minuteController = FixedExtentScrollController(
|
||||
initialItem: _filteredMinutes.indexOf(_selectedMinute),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user