feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
import '../../../../data/cache/cache_policy.dart';
|
||||
import '../../../../data/cache/cached_repository.dart';
|
||||
import '../../../../data/cache/cache_scope.dart';
|
||||
import '../../../../data/network/i_api_client.dart';
|
||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||
import '../models/schedule_item_model.dart';
|
||||
|
||||
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
final IApiClient _apiClient;
|
||||
final ReminderReconcileService? _reminderReconcileService;
|
||||
static const _prefix = '/api/v1/schedule-items';
|
||||
|
||||
CalendarRepository({
|
||||
required super.store,
|
||||
required IApiClient apiClient,
|
||||
ReminderReconcileService? reminderReconcileService,
|
||||
CachePolicy? policy,
|
||||
super.now,
|
||||
}) : _apiClient = apiClient,
|
||||
_reminderReconcileService = reminderReconcileService,
|
||||
super(
|
||||
policy:
|
||||
policy ??
|
||||
@@ -69,7 +75,9 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventById response: empty payload');
|
||||
}
|
||||
return ScheduleItemModel.fromJson(data);
|
||||
final event = ScheduleItemModel.fromJson(data);
|
||||
await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event));
|
||||
return event;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> getById(String id) {
|
||||
@@ -91,11 +99,20 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
}
|
||||
|
||||
Future<void> acceptSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/accept');
|
||||
return _applySubscriptionAction(itemId, accept: true);
|
||||
}
|
||||
|
||||
Future<void> rejectSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/reject');
|
||||
return _applySubscriptionAction(itemId, accept: false);
|
||||
}
|
||||
|
||||
Future<void> _applySubscriptionAction(
|
||||
String itemId, {
|
||||
required bool accept,
|
||||
}) async {
|
||||
final action = accept ? 'accept' : 'reject';
|
||||
await _apiClient.post<void>('$_prefix/$itemId/$action');
|
||||
await store.clearByPrefix('cache:${CacheScope.token()}:calendar:');
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> _listByRange({
|
||||
@@ -111,10 +128,28 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid listByRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
final events = data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ScheduleItemModel.fromJson)
|
||||
.toList(growable: false);
|
||||
await _reminderReconcileService?.reconcileEvents(
|
||||
events.map(_toReminderSnapshot).toList(growable: false),
|
||||
);
|
||||
return events;
|
||||
}
|
||||
|
||||
ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) {
|
||||
return ReminderEventSnapshot(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.metadata?.reminderMinutes,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
isArchived: event.status == ScheduleStatus.archived,
|
||||
);
|
||||
}
|
||||
|
||||
static Object? _encodeEventList(List<ScheduleItemModel> events) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import '../../../../data/network/i_api_client.dart';
|
||||
import '../../../../data/cache/cache_store.dart';
|
||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||
import '../models/schedule_item_model.dart';
|
||||
|
||||
class CalendarService {
|
||||
@@ -7,12 +9,15 @@ class CalendarService {
|
||||
|
||||
final IApiClient _apiClient;
|
||||
final CacheInvalidator _invalidator;
|
||||
final ReminderReconcileService? _reminderReconcileService;
|
||||
|
||||
CalendarService({
|
||||
required IApiClient apiClient,
|
||||
required CacheInvalidator invalidator,
|
||||
ReminderReconcileService? reminderReconcileService,
|
||||
}) : _apiClient = apiClient,
|
||||
_invalidator = invalidator;
|
||||
_invalidator = invalidator,
|
||||
_reminderReconcileService = reminderReconcileService;
|
||||
|
||||
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
|
||||
final start = DateTime(date.year, date.month, date.day);
|
||||
@@ -35,10 +40,14 @@ class CalendarService {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventsForRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
final events = data
|
||||
.map((item) => item as Map<String, dynamic>)
|
||||
.map(ScheduleItemModel.fromJson)
|
||||
.toList(growable: false);
|
||||
await _reminderReconcileService?.reconcileEvents(
|
||||
events.map(_toReminderSnapshot).toList(growable: false),
|
||||
);
|
||||
return events;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> getEventById(String id) async {
|
||||
@@ -47,7 +56,9 @@ class CalendarService {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventById response: empty payload');
|
||||
}
|
||||
return ScheduleItemModel.fromJson(data);
|
||||
final event = ScheduleItemModel.fromJson(data);
|
||||
await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event));
|
||||
return event;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
|
||||
@@ -61,6 +72,9 @@ class CalendarService {
|
||||
}
|
||||
final created = ScheduleItemModel.fromJson(data);
|
||||
_invalidateEventCache(created);
|
||||
await _reminderReconcileService?.reconcileEvent(
|
||||
_toReminderSnapshot(created),
|
||||
);
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -75,6 +89,9 @@ class CalendarService {
|
||||
}
|
||||
final updated = ScheduleItemModel.fromJson(data);
|
||||
_invalidateEventCache(updated);
|
||||
await _reminderReconcileService?.reconcileEvent(
|
||||
_toReminderSnapshot(updated),
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -84,6 +101,7 @@ class CalendarService {
|
||||
event.copyWith(status: ScheduleStatus.archived),
|
||||
);
|
||||
_invalidateEventCache(updatedEvent);
|
||||
await _reminderReconcileService?.archiveAndCancel(id);
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
@@ -91,6 +109,7 @@ class CalendarService {
|
||||
final event = await getEventById(id);
|
||||
_invalidateEventCache(event);
|
||||
await _apiClient.delete<void>('$_prefix/$id');
|
||||
await _reminderReconcileService?.archiveAndCancel(id);
|
||||
}
|
||||
|
||||
void _invalidateEventCache(ScheduleItemModel event) {
|
||||
@@ -109,4 +128,18 @@ class CalendarService {
|
||||
current = current.add(const Duration(days: 1));
|
||||
}
|
||||
}
|
||||
|
||||
ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) {
|
||||
return ReminderEventSnapshot(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.metadata?.reminderMinutes,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
isArchived: event.status == ScheduleStatus.archived,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_weekdayLabel(date),
|
||||
_weekdayLabel(context, date),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isWeekend
|
||||
@@ -492,8 +492,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
);
|
||||
}
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
String _weekdayLabel(BuildContext context, DateTime date) {
|
||||
final labels = [
|
||||
context.l10n.calendarMonthWeekdaySunShort,
|
||||
context.l10n.calendarMonthWeekdayMonShort,
|
||||
context.l10n.calendarMonthWeekdayTueShort,
|
||||
context.l10n.calendarMonthWeekdayWedShort,
|
||||
context.l10n.calendarMonthWeekdayThuShort,
|
||||
context.l10n.calendarMonthWeekdayFriShort,
|
||||
context.l10n.calendarMonthWeekdaySatShort,
|
||||
];
|
||||
return labels[date.weekday % 7];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
import '../../data/services/calendar_service.dart';
|
||||
|
||||
class CalendarReminderAlarmScreen extends StatefulWidget {
|
||||
const CalendarReminderAlarmScreen({super.key, required this.eventId});
|
||||
|
||||
final String eventId;
|
||||
|
||||
@override
|
||||
State<CalendarReminderAlarmScreen> createState() =>
|
||||
_CalendarReminderAlarmScreenState();
|
||||
}
|
||||
|
||||
class _CalendarReminderAlarmScreenState
|
||||
extends State<CalendarReminderAlarmScreen> {
|
||||
late final Future<ScheduleItemModel> _eventFuture;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventFuture = sl<CalendarService>().getEventById(widget.eventId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorScheme.surface,
|
||||
title: Text(context.l10n.notificationChannelName),
|
||||
),
|
||||
body: FutureBuilder<ScheduleItemModel>(
|
||||
future: _eventFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (snapshot.hasError || snapshot.data == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Text(
|
||||
context.l10n.errorRequestFailed,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final event = snapshot.data!;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_EventCard(event: event),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
text: context.l10n.notificationSnoozeLater,
|
||||
isOutlined: true,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => _snoozeEvent(event),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
text: context.l10n.calendarDetailArchiveConfirm,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => _archiveEvent(event),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _archiveEvent(ScheduleItemModel event) async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
try {
|
||||
await sl<CalendarService>().archiveEvent(event.id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(AppRoutes.calendarEventDetail(event.id));
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.calendarDetailArchiveFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _snoozeEvent(ScheduleItemModel event) async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
try {
|
||||
await sl<ReminderReconcileService>().snooze10m(_snapshotFromEvent(event));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(context, context.l10n.notificationSnoozeMinutes(10));
|
||||
context.go(AppRoutes.calendarEventDetail(event.id));
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.todoSaveFailed('snooze failed'),
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReminderEventSnapshot _snapshotFromEvent(ScheduleItemModel event) {
|
||||
return ReminderEventSnapshot(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.metadata?.reminderMinutes,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
isArchived: event.status == ScheduleStatus.archived,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventCard extends StatelessWidget {
|
||||
const _EventCard({required this.event});
|
||||
|
||||
final ScheduleItemModel event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final formatter = DateFormat('MM-dd HH:mm');
|
||||
final endAt = event.endAt;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
endAt == null
|
||||
? formatter.format(event.startAt)
|
||||
: '${formatter.format(event.startAt)} - ${formatter.format(endAt)}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if ((event.metadata?.location?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
context.l10n.notificationLocation(event.metadata!.location!),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if ((event.metadata?.notes?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
context.l10n.notificationNotes(event.metadata!.notes!),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user