refactor(apps): 主题系统迁移至 ColorScheme + 扩展架构并支持 Dark Mode

This commit is contained in:
qzl
2026-03-27 19:07:39 +08:00
parent ecc1ec6ce4
commit ae29a8209b
146 changed files with 4301 additions and 3200 deletions
+6
View File
@@ -0,0 +1,6 @@
class CacheEntry<T> {
final T value;
final DateTime fetchedAt;
const CacheEntry({required this.value, required this.fetchedAt});
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:async';
import 'hybrid_cache_store.dart';
class CacheInvalidator {
final HybridCacheStore? _store;
final Set<String> _invalidated = <String>{};
CacheInvalidator({HybridCacheStore? store}) : _store = store;
void invalidate(String key) {
_invalidated.add(key);
final store = _store;
if (store != null) {
unawaited(store.remove(key));
}
}
void invalidateCalendarDay(DateTime date) {
final month = '${date.year}-${date.month.toString().padLeft(2, '0')}';
final day = '$month-${date.day.toString().padLeft(2, '0')}';
invalidate('calendar:day:$day');
invalidate('calendar:month:$month');
}
bool wasInvalidated(String key) => _invalidated.contains(key);
}
+49
View File
@@ -0,0 +1,49 @@
class CacheDecision {
final bool canUseCached;
final bool shouldRefreshInBackground;
final bool mustBlockForNetwork;
const CacheDecision({
required this.canUseCached,
required this.shouldRefreshInBackground,
required this.mustBlockForNetwork,
});
}
class CachePolicy {
final Duration softTtl;
final Duration hardTtl;
final Duration minRefreshInterval;
const CachePolicy({
required this.softTtl,
required this.hardTtl,
this.minRefreshInterval = Duration.zero,
});
CacheDecision evaluate({required DateTime now, required DateTime fetchedAt}) {
final age = now.difference(fetchedAt);
if (age >= hardTtl) {
return const CacheDecision(
canUseCached: false,
shouldRefreshInBackground: false,
mustBlockForNetwork: true,
);
}
if (age >= softTtl) {
final shouldRefresh = age >= minRefreshInterval;
return CacheDecision(
canUseCached: true,
shouldRefreshInBackground: shouldRefresh,
mustBlockForNetwork: false,
);
}
return const CacheDecision(
canUseCached: true,
shouldRefreshInBackground: false,
mustBlockForNetwork: false,
);
}
}
+5
View File
@@ -0,0 +1,5 @@
abstract class CacheStore {
Future<T?> read<T>(String key);
Future<void> write<T>(String key, T value);
Future<void> remove(String key);
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:async';
import 'cache_entry.dart';
import 'cache_policy.dart';
import 'hybrid_cache_store.dart';
abstract class CachedRepository<T> {
final HybridCacheStore store;
final CachePolicy policy;
final DateTime Function() now;
final Map<String, Future<void>> _refreshInFlight = <String, Future<void>>{};
CachedRepository({
required this.store,
required this.policy,
DateTime Function()? now,
}) : now = now ?? DateTime.now;
Future<T> getOrLoad({
required String key,
required Future<T> Function() loadFromRemote,
bool Function(T loaded)? shouldWriteLoaded,
bool forceRefresh = false,
}) async {
if (forceRefresh) {
return _refreshAndWrite(
key,
loadFromRemote,
shouldWriteLoaded: shouldWriteLoaded,
);
}
final cached = await readCacheEntry(key);
if (cached == null) {
return _refreshAndWrite(
key,
loadFromRemote,
shouldWriteLoaded: shouldWriteLoaded,
);
}
final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt);
if (decision.shouldRefreshInBackground) {
refreshInBackground(
key: key,
loadFromRemote: loadFromRemote,
shouldWriteLoaded: shouldWriteLoaded,
);
}
if (decision.mustBlockForNetwork || !decision.canUseCached) {
return _refreshAndWrite(
key,
loadFromRemote,
shouldWriteLoaded: shouldWriteLoaded,
);
}
return cached.value;
}
Future<CacheEntry<T>?> readCacheEntry(String key) {
return store.read<CacheEntry<T>>(key);
}
Future<void> writeCacheEntry(String key, T value) {
return store.write<CacheEntry<T>>(
key,
CacheEntry<T>(value: value, fetchedAt: now()),
);
}
Future<void> removeCacheKey(String key) {
return store.remove(key);
}
void refreshInBackground({
required String key,
required Future<T> Function() loadFromRemote,
bool Function(T loaded)? shouldWriteLoaded,
}) {
if (_refreshInFlight.containsKey(key)) {
return;
}
final task = _refreshAndWrite(
key,
loadFromRemote,
shouldWriteLoaded: shouldWriteLoaded,
).then((_) {});
final tracked = task.whenComplete(() {
_refreshInFlight.remove(key);
});
_refreshInFlight[key] = tracked;
unawaited(tracked);
}
Future<T> _refreshAndWrite(
String key,
Future<T> Function() loadFromRemote, {
bool Function(T loaded)? shouldWriteLoaded,
}) async {
final remote = await loadFromRemote();
if (shouldWriteLoaded != null && !shouldWriteLoaded(remote)) {
return remote;
}
await writeCacheEntry(key, remote);
return remote;
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'memory_cache_store.dart';
import 'persistent_cache_store.dart';
class HybridCacheStore {
final MemoryCacheStore memory;
final PersistentCacheStore persistent;
final Map<String, Future<dynamic>> _inflight = <String, Future<dynamic>>{};
HybridCacheStore({required this.memory, required this.persistent});
Future<T?> read<T>(String key) async {
final memoryValue = await memory.read<T>(key);
if (memoryValue != null) {
return memoryValue;
}
final persistentValue = await persistent.read<T>(key);
if (persistentValue != null) {
await memory.write(key, persistentValue);
}
return persistentValue;
}
Future<void> write<T>(String key, T value) async {
await memory.write<T>(key, value);
await persistent.write<T>(key, value);
}
Future<void> remove(String key) async {
await memory.remove(key);
await persistent.remove(key);
}
Future<T> getOrLoad<T>(String key, {required Future<T> Function() loader}) {
final running = _inflight[key];
if (running != null) {
return running.then((value) => value as T);
}
final future = () async {
final cached = await read<T>(key);
if (cached != null) {
return cached;
}
final loaded = await loader();
await write<T>(key, loaded);
return loaded;
}();
_inflight[key] = future;
return future.whenComplete(() {
_inflight.remove(key);
});
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'cache_store.dart';
class MemoryCacheStore implements CacheStore {
final Map<String, Object?> _values = <String, Object?>{};
@override
Future<T?> read<T>(String key) async {
final value = _values[key];
if (value is T) {
return value;
}
return null;
}
@override
Future<void> write<T>(String key, T value) async {
_values[key] = value;
}
@override
Future<void> remove(String key) async {
_values.remove(key);
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'cache_store.dart';
class PersistentCacheStore implements CacheStore {
final Map<String, Object?> _values = <String, Object?>{};
@override
Future<T?> read<T>(String key) async {
final value = _values[key];
if (value is T) {
return value;
}
return null;
}
@override
Future<void> write<T>(String key, T value) async {
_values[key] = value;
}
@override
Future<void> remove(String key) async {
_values.remove(key);
}
}
+182
View File
@@ -0,0 +1,182 @@
class ReminderPayload {
final String eventId;
final String title;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final String? location;
final String? notes;
final String? color;
final ReminderPayloadMode mode;
final List<String> aggregateIds;
final int? fireTimeBucket;
final int version;
const ReminderPayload({
required this.eventId,
required this.title,
required this.startAt,
required this.timezone,
this.endAt,
this.location,
this.notes,
this.color,
this.mode = ReminderPayloadMode.single,
this.aggregateIds = const [],
this.fireTimeBucket,
this.version = 1,
});
ReminderPayload copyWith({
String? eventId,
String? title,
DateTime? startAt,
DateTime? endAt,
String? timezone,
String? location,
String? notes,
String? color,
ReminderPayloadMode? mode,
List<String>? aggregateIds,
int? fireTimeBucket,
int? version,
}) {
return ReminderPayload(
eventId: eventId ?? this.eventId,
title: title ?? this.title,
startAt: startAt ?? this.startAt,
endAt: endAt ?? this.endAt,
timezone: timezone ?? this.timezone,
location: location ?? this.location,
notes: notes ?? this.notes,
color: color ?? this.color,
mode: mode ?? this.mode,
aggregateIds: aggregateIds ?? this.aggregateIds,
fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
version: version ?? this.version,
);
}
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'title': title,
'startAt': startAt.toIso8601String(),
'endAt': endAt?.toIso8601String(),
'timezone': timezone,
'location': location,
'notes': notes,
'color': color,
'mode': mode.value,
'aggregateIds': aggregateIds,
'fireTimeBucket': fireTimeBucket,
'version': version,
};
}
factory ReminderPayload.fromJson(Map<String, dynamic> json) {
final eventId = (json['eventId'] as String?) ?? '';
if (eventId.isEmpty) {
throw const FormatException('eventId is required');
}
final startAtRaw = json['startAt'] as String?;
if (startAtRaw == null || startAtRaw.isEmpty) {
throw const FormatException('startAt is required');
}
final parsedStartAt = DateTime.parse(startAtRaw);
final mode = ReminderPayloadMode.fromValue(
(json['mode'] as String?) ?? 'single',
);
final aggregateIds = (json['aggregateIds'] as List<dynamic>? ?? const [])
.map((item) => item.toString())
.toList();
if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) {
throw const FormatException('aggregateIds must contain at least 2 items');
}
return ReminderPayload(
eventId: eventId,
title: (json['title'] as String?) ?? '',
startAt: parsedStartAt,
endAt: json['endAt'] != null
? DateTime.parse(json['endAt'] as String)
: null,
timezone: (json['timezone'] as String?) ?? 'UTC',
location: json['location'] as String?,
notes: json['notes'] as String?,
color: json['color'] as String?,
mode: mode,
aggregateIds: aggregateIds,
fireTimeBucket: json['fireTimeBucket'] as int?,
version: (json['version'] as int?) ?? 1,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ReminderPayload &&
other.eventId == eventId &&
other.title == title &&
other.startAt == startAt &&
other.endAt == endAt &&
other.timezone == timezone &&
other.location == location &&
other.notes == notes &&
other.color == color &&
other.mode == mode &&
_listEquals(other.aggregateIds, aggregateIds) &&
other.fireTimeBucket == fireTimeBucket &&
other.version == version;
}
@override
int get hashCode {
return Object.hash(
eventId,
title,
startAt,
endAt,
timezone,
location,
notes,
color,
mode,
Object.hashAll(aggregateIds),
fireTimeBucket,
version,
);
}
}
enum ReminderPayloadMode {
single('single'),
aggregate('aggregate');
const ReminderPayloadMode(this.value);
final String value;
static ReminderPayloadMode fromValue(String raw) {
return ReminderPayloadMode.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderPayloadMode.single,
);
}
}
bool _listEquals(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
for (var i = 0; i < left.length; i++) {
if (left[i] != right[i]) {
return false;
}
}
return true;
}
+41
View File
@@ -0,0 +1,41 @@
class UserProfile {
final String id;
final String username;
final String? phone;
final String? avatarUrl;
final String? bio;
const UserProfile({
required this.id,
required this.username,
this.phone,
this.avatarUrl,
this.bio,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
id: json['id'] as String,
username: json['username'] as String,
phone: json['phone'] as String?,
avatarUrl: json['avatar_url'] as String?,
bio: json['bio'] as String?,
);
}
}
class UserUpdateRequest {
final String? username;
final String? avatarUrl;
final String? bio;
const UserUpdateRequest({this.username, this.avatarUrl, this.bio});
Map<String, dynamic> toJson() {
return {
if (username != null) 'username': username,
if (avatarUrl != null) 'avatar_url': avatarUrl,
if (bio != null) 'bio': bio,
};
}
}
@@ -0,0 +1,60 @@
import '../../core/network/i_api_client.dart';
import 'models/calendar_event.dart';
abstract class CalendarEventRepository {
Future<List<CalendarEvent>> listByRange({
required DateTime startAt,
required DateTime endAt,
});
Future<CalendarEvent> getById(String id);
Future<void> acceptSubscription(String itemId);
Future<void> rejectSubscription(String itemId);
}
class CalendarEventRepositoryImpl implements CalendarEventRepository {
final IApiClient _apiClient;
static const _prefix = '/api/v1/schedule-items';
CalendarEventRepositoryImpl(this._apiClient);
@override
Future<List<CalendarEvent>> listByRange({
required DateTime startAt,
required DateTime endAt,
}) async {
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
final response = await _apiClient.get<List<dynamic>>(
'$_prefix?start_at=$start&end_at=$end',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid listByRange response: empty payload');
}
return data
.whereType<Map<String, dynamic>>()
.map(CalendarEvent.fromJson)
.toList(growable: false);
}
@override
Future<CalendarEvent> getById(String id) async {
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
final event = response.data;
if (event == null) {
throw StateError('Invalid getById response: empty payload');
}
return CalendarEvent.fromJson(event);
}
@override
Future<void> acceptSubscription(String itemId) {
return _apiClient.post<void>('$_prefix/$itemId/accept');
}
@override
Future<void> rejectSubscription(String itemId) {
return _apiClient.post<void>('$_prefix/$itemId/reject');
}
}
@@ -0,0 +1,98 @@
import '../cache/cache_policy.dart';
import '../cache/cached_repository.dart';
import '../../core/network/i_api_client.dart';
import 'models/schedule_item_model.dart';
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
final IApiClient _apiClient;
static const _prefix = '/api/v1/schedule-items';
CalendarRepository({
required super.store,
required IApiClient apiClient,
CachePolicy? policy,
super.now,
}) : _apiClient = apiClient,
super(
policy:
policy ??
const CachePolicy(
softTtl: Duration(minutes: 2),
hardTtl: Duration(minutes: 30),
minRefreshInterval: Duration(minutes: 1),
),
);
static String dayKey(DateTime date) {
final day =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return 'calendar:day:$day';
}
static String monthKey(DateTime date) {
return 'calendar:month:${date.year}-${date.month.toString().padLeft(2, '0')}';
}
Future<List<ScheduleItemModel>> getDayEvents(
DateTime date, {
bool forceRefresh = false,
}) async {
final key = dayKey(date);
final start = DateTime(date.year, date.month, date.day);
final end = DateTime(date.year, date.month, date.day, 23, 59, 59);
return getOrLoad(
key: key,
forceRefresh: forceRefresh,
loadFromRemote: () => _listByRange(startAt: start, endAt: end),
);
}
Future<List<ScheduleItemModel>> getMonthEvents(
DateTime monthStart, {
bool forceRefresh = false,
}) async {
final key = monthKey(monthStart);
final start = DateTime(monthStart.year, monthStart.month, 1);
final end = DateTime(monthStart.year, monthStart.month + 1, 0, 23, 59, 59);
return getOrLoad(
key: key,
forceRefresh: forceRefresh,
loadFromRemote: () => _listByRange(startAt: start, endAt: end),
);
}
Future<ScheduleItemModel> getEventById(String id) async {
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
final data = response.data;
if (data == null) {
throw StateError('Invalid getEventById response: empty payload');
}
return ScheduleItemModel.fromJson(data);
}
Future<List<ScheduleItemModel>> listEventsByRange({
required DateTime start,
required DateTime end,
}) {
return _listByRange(startAt: start, endAt: end);
}
Future<List<ScheduleItemModel>> _listByRange({
required DateTime startAt,
required DateTime endAt,
}) async {
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
final response = await _apiClient.get<List<dynamic>>(
'$_prefix?start_at=$start&end_at=$end',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid listByRange response: empty payload');
}
return data
.whereType<Map<String, dynamic>>()
.map(ScheduleItemModel.fromJson)
.toList(growable: false);
}
}
@@ -0,0 +1,88 @@
import '../../core/network/i_api_client.dart';
import 'models/friend_request.dart';
abstract class FriendRepository {
Future<List<FriendUser>> getFriends();
Future<FriendRequest> getRequestById(String friendshipId);
Future<Map<String, FriendRequest>> getRequestsByIds(
List<String> friendshipIds,
);
Future<FriendRequest> acceptRequest(String friendshipId);
Future<FriendRequest> declineRequest(String friendshipId);
}
class FriendRepositoryImpl implements FriendRepository {
final IApiClient _apiClient;
static const _prefix = '/api/v1/friends';
FriendRepositoryImpl(this._apiClient);
@override
Future<List<FriendUser>> getFriends() async {
final response = await _apiClient.get<List<dynamic>>(_prefix);
final data = response.data;
if (data == null) {
throw StateError('Invalid getFriends response: empty payload');
}
return data
.map((item) => item as Map<String, dynamic>)
.map(
(item) => FriendUser.fromJson(item['friend'] as Map<String, dynamic>),
)
.toList(growable: false);
}
@override
Future<FriendRequest> getRequestById(String friendshipId) async {
final response = await _apiClient.get<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getRequestById response: empty payload');
}
return FriendRequest.fromJson(data);
}
@override
Future<Map<String, FriendRequest>> getRequestsByIds(
List<String> friendshipIds,
) async {
if (friendshipIds.isEmpty) {
return const <String, FriendRequest>{};
}
final pairs = await Future.wait(
friendshipIds.map((id) async {
final request = await getRequestById(id);
return MapEntry(id, request);
}),
);
return Map<String, FriendRequest>.fromEntries(pairs);
}
@override
Future<FriendRequest> acceptRequest(String friendshipId) async {
final response = await _apiClient.post<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId/accept',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid acceptRequest response: empty payload');
}
return FriendRequest.fromJson(data);
}
@override
Future<FriendRequest> declineRequest(String friendshipId) async {
final response = await _apiClient.post<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId/decline',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid declineRequest response: empty payload');
}
return FriendRequest.fromJson(data);
}
}
@@ -0,0 +1,42 @@
import '../../core/network/i_api_client.dart';
import 'models/inbox_message.dart';
abstract class InboxRepository {
Future<List<InboxMessage>> getMessages({bool? isRead});
Future<InboxMessage> markAsRead(String messageId);
}
class InboxRepositoryImpl implements InboxRepository {
final IApiClient _apiClient;
static const _prefix = '/api/v1/inbox/messages';
InboxRepositoryImpl(this._apiClient);
@override
Future<List<InboxMessage>> getMessages({bool? isRead}) async {
final queryParams = isRead != null ? '?is_read=$isRead' : '';
final response = await _apiClient.get<List<dynamic>>(
'$_prefix$queryParams',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getMessages response: empty payload');
}
return data
.whereType<Map<String, dynamic>>()
.map(InboxMessage.fromJson)
.toList(growable: false);
}
@override
Future<InboxMessage> markAsRead(String messageId) async {
final response = await _apiClient.patch<Map<String, dynamic>>(
'$_prefix/$messageId/read',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid markAsRead response: empty payload');
}
return InboxMessage.fromJson(data);
}
}
@@ -0,0 +1,40 @@
enum CalendarEventStatus { active, archived }
class CalendarEvent {
final String id;
final String title;
final DateTime startAt;
final DateTime? endAt;
final CalendarEventStatus status;
const CalendarEvent({
required this.id,
required this.title,
required this.startAt,
required this.endAt,
required this.status,
});
factory CalendarEvent.fromJson(Map<String, dynamic> json) {
return CalendarEvent(
id: json['id'] as String,
title: json['title'] as String,
startAt: DateTime.parse(json['start_at'] as String).toLocal(),
endAt: json['end_at'] == null
? null
: DateTime.parse(json['end_at'] as String).toLocal(),
status: _calendarEventStatusFromApi(json['status'] as String),
);
}
}
CalendarEventStatus _calendarEventStatusFromApi(String raw) {
switch (raw) {
case 'active':
return CalendarEventStatus.active;
case 'archived':
return CalendarEventStatus.archived;
default:
throw StateError('Unsupported calendar event status: $raw');
}
}
@@ -0,0 +1,63 @@
enum FriendRequestStatus { pending, accepted, rejected }
class FriendUser {
final String id;
final String username;
final String? avatarUrl;
const FriendUser({
required this.id,
required this.username,
required this.avatarUrl,
});
factory FriendUser.fromJson(Map<String, dynamic> json) {
return FriendUser(
id: json['id'] as String,
username: json['username'] as String,
avatarUrl: json['avatar_url'] as String?,
);
}
}
class FriendRequest {
final String id;
final FriendUser sender;
final FriendUser recipient;
final String? content;
final FriendRequestStatus status;
final DateTime createdAt;
const FriendRequest({
required this.id,
required this.sender,
required this.recipient,
required this.content,
required this.status,
required this.createdAt,
});
factory FriendRequest.fromJson(Map<String, dynamic> json) {
return FriendRequest(
id: json['id'] as String,
sender: FriendUser.fromJson(json['sender'] as Map<String, dynamic>),
recipient: FriendUser.fromJson(json['recipient'] as Map<String, dynamic>),
content: json['content'] as String?,
status: _friendRequestStatusFromApi(json['status'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
);
}
}
FriendRequestStatus _friendRequestStatusFromApi(String raw) {
switch (raw) {
case 'pending':
return FriendRequestStatus.pending;
case 'accepted':
return FriendRequestStatus.accepted;
case 'rejected':
return FriendRequestStatus.rejected;
default:
throw StateError('Unsupported friend request status: $raw');
}
}
@@ -0,0 +1,74 @@
enum InboxMessageType { friendRequest, calendar, system, group }
enum InboxMessageStatus { pending, accepted, rejected, dismissed }
class InboxMessage {
final String id;
final String recipientId;
final String? senderId;
final InboxMessageType messageType;
final String? scheduleItemId;
final String? friendshipId;
final Map<String, dynamic>? content;
final bool isRead;
final InboxMessageStatus status;
final DateTime createdAt;
const InboxMessage({
required this.id,
required this.recipientId,
required this.senderId,
required this.messageType,
required this.scheduleItemId,
required this.friendshipId,
required this.content,
required this.isRead,
required this.status,
required this.createdAt,
});
factory InboxMessage.fromJson(Map<String, dynamic> json) {
return InboxMessage(
id: json['id'] as String,
recipientId: json['recipient_id'] as String,
senderId: json['sender_id'] as String?,
messageType: _messageTypeFromApi(json['message_type'] as String),
scheduleItemId: json['schedule_item_id'] as String?,
friendshipId: json['friendship_id'] as String?,
content: json['content'] as Map<String, dynamic>?,
isRead: json['is_read'] as bool,
status: _messageStatusFromApi(json['status'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
);
}
}
InboxMessageType _messageTypeFromApi(String raw) {
switch (raw) {
case 'friend_request':
return InboxMessageType.friendRequest;
case 'calendar':
return InboxMessageType.calendar;
case 'system':
return InboxMessageType.system;
case 'group':
return InboxMessageType.group;
default:
throw StateError('Unsupported inbox message type: $raw');
}
}
InboxMessageStatus _messageStatusFromApi(String raw) {
switch (raw) {
case 'pending':
return InboxMessageStatus.pending;
case 'accepted':
return InboxMessageStatus.accepted;
case 'rejected':
return InboxMessageStatus.rejected;
case 'dismissed':
return InboxMessageStatus.dismissed;
default:
throw StateError('Unsupported inbox message status: $raw');
}
}
@@ -0,0 +1,287 @@
enum ScheduleSourceType { manual, imported, agentGenerated }
enum ScheduleStatus { active, archived }
class ScheduleItemModel {
final String id;
final String ownerId;
final int permission;
final bool isOwner;
final String title;
final String? description;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final ScheduleMetadata? metadata;
final ScheduleSourceType sourceType;
final ScheduleStatus status;
final DateTime createdAt;
final DateTime updatedAt;
static const int permissionView = 1;
static const int permissionInvite = 2;
static const int permissionEdit = 4;
bool get canEdit => isOwner || (permission & permissionEdit) != 0;
bool get canInvite => isOwner || (permission & permissionInvite) != 0;
bool get canDelete => isOwner;
ScheduleItemModel({
required this.id,
required this.ownerId,
this.permission = 1,
this.isOwner = false,
required this.title,
this.description,
required this.startAt,
this.endAt,
this.timezone = 'Asia/Shanghai',
this.metadata,
this.sourceType = ScheduleSourceType.manual,
this.status = ScheduleStatus.active,
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
ScheduleItemModel copyWith({
String? id,
String? ownerId,
int? permission,
bool? isOwner,
String? title,
String? description,
DateTime? startAt,
DateTime? endAt,
String? timezone,
ScheduleMetadata? metadata,
ScheduleSourceType? sourceType,
ScheduleStatus? status,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return ScheduleItemModel(
id: id ?? this.id,
ownerId: ownerId ?? this.ownerId,
permission: permission ?? this.permission,
isOwner: isOwner ?? this.isOwner,
title: title ?? this.title,
description: description ?? this.description,
startAt: startAt ?? this.startAt,
endAt: endAt ?? this.endAt,
timezone: timezone ?? this.timezone,
metadata: metadata ?? this.metadata,
sourceType: sourceType ?? this.sourceType,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
factory ScheduleItemModel.fromJson(Map<String, dynamic> json) {
return ScheduleItemModel(
id: json['id'] as String,
ownerId: json['owner_id'] as String,
permission: json['permission'] as int,
isOwner: json['is_owner'] as bool,
title: json['title'] as String,
description: json['description'] as String?,
startAt: DateTime.parse(json['start_at'] as String).toLocal(),
endAt: json['end_at'] != null
? DateTime.parse(json['end_at'] as String).toLocal()
: null,
timezone: json['timezone'] as String,
metadata: json['metadata'] is Map<String, dynamic>
? ScheduleMetadata.fromJson(json['metadata'] as Map<String, dynamic>)
: null,
sourceType: _sourceTypeFromApi(json['source_type'] as String),
status: _statusFromApi(json['status'] as String),
createdAt: DateTime.parse(json['created_at'] as String).toLocal(),
updatedAt: DateTime.parse(json['updated_at'] as String).toLocal(),
);
}
Map<String, dynamic> toCreateJson() {
return {
'title': title,
'description': description,
'start_at': startAt.toUtc().toIso8601String(),
'end_at': endAt?.toUtc().toIso8601String(),
'timezone': timezone,
'metadata': metadata?.toJson(),
};
}
Map<String, dynamic> toUpdateJson() {
return {
'title': title,
'description': description,
'start_at': startAt.toUtc().toIso8601String(),
'end_at': endAt?.toUtc().toIso8601String(),
'timezone': timezone,
'metadata': metadata?.toJson(),
'status': _statusToApi(status),
};
}
}
class ScheduleMetadata {
final String? color;
final String? location;
final String? notes;
final int? reminderMinutes;
final List<Attachment> attachments;
final int version;
final Map<String, dynamic> raw;
ScheduleMetadata({
this.color,
this.location,
this.notes,
this.reminderMinutes,
List<Attachment>? attachments,
this.version = 1,
Map<String, dynamic>? raw,
}) : attachments = attachments ?? const [],
raw = raw ?? const {};
ScheduleMetadata copyWith({
String? color,
String? location,
String? notes,
int? reminderMinutes,
List<Attachment>? attachments,
int? version,
Map<String, dynamic>? raw,
}) {
return ScheduleMetadata(
color: color ?? this.color,
location: location ?? this.location,
notes: notes ?? this.notes,
reminderMinutes: reminderMinutes ?? this.reminderMinutes,
attachments: attachments ?? this.attachments,
version: version ?? this.version,
raw: raw ?? this.raw,
);
}
factory ScheduleMetadata.fromJson(Map<String, dynamic> json) {
final rawAttachments = json['attachments'] as List<dynamic>;
final attachments = rawAttachments
.map((item) => Attachment.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
return ScheduleMetadata(
color: json['color'] as String?,
location: json['location'] as String?,
notes: json['notes'] as String?,
reminderMinutes: json['reminder_minutes'] as int?,
attachments: attachments,
version: json['version'] as int,
raw: Map<String, dynamic>.from(json),
);
}
Map<String, dynamic> toJson() {
return {
'color': color,
'location': location,
'notes': notes,
'reminder_minutes': reminderMinutes,
'attachments': attachments.map((item) => item.toJson()).toList(),
'version': version,
};
}
}
class Attachment {
final String name;
final List<String> visibleTo;
final String? url;
final String? note;
final String? content;
final String type;
Attachment({
required this.name,
this.visibleTo = const [],
this.url,
this.note,
this.content,
this.type = 'document',
});
Attachment copyWith({
String? name,
List<String>? visibleTo,
String? url,
String? note,
String? content,
String? type,
}) {
return Attachment(
name: name ?? this.name,
visibleTo: visibleTo ?? this.visibleTo,
url: url ?? this.url,
note: note ?? this.note,
content: content ?? this.content,
type: type ?? this.type,
);
}
factory Attachment.fromJson(Map<String, dynamic> json) {
final rawVisibleTo = json['visible_to'] as List<dynamic>;
final visibleTo = rawVisibleTo.map((item) => item.toString()).toList();
return Attachment(
name: json['name'] as String,
visibleTo: visibleTo,
url: json['url'] as String?,
note: json['note'] as String?,
content: json['content'] as String?,
type: json['type'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'visible_to': visibleTo,
'url': url,
'note': note,
'content': content,
'type': type,
};
}
}
ScheduleSourceType _sourceTypeFromApi(String raw) {
switch (raw) {
case 'imported':
return ScheduleSourceType.imported;
case 'agent_generated':
return ScheduleSourceType.agentGenerated;
case 'manual':
return ScheduleSourceType.manual;
default:
throw StateError('Unsupported schedule source type: $raw');
}
}
ScheduleStatus _statusFromApi(String raw) {
switch (raw) {
case 'archived':
return ScheduleStatus.archived;
case 'active':
return ScheduleStatus.active;
default:
throw StateError('Unsupported schedule status: $raw');
}
}
String _statusToApi(ScheduleStatus status) {
switch (status) {
case ScheduleStatus.active:
return 'active';
case ScheduleStatus.archived:
return 'archived';
}
}
@@ -0,0 +1,19 @@
class UserSummary {
final String id;
final String username;
final String? avatarUrl;
const UserSummary({
required this.id,
required this.username,
required this.avatarUrl,
});
factory UserSummary.fromJson(Map<String, dynamic> json) {
return UserSummary(
id: json['id'] as String,
username: json['username'] as String,
avatarUrl: json['avatar_url'] as String?,
);
}
}
@@ -0,0 +1,36 @@
import '../../core/network/i_api_client.dart';
import 'models/user_summary.dart';
abstract class UserRepository {
Future<UserSummary> getById(String userId);
Future<UserSummary> getMe();
}
class UserRepositoryImpl implements UserRepository {
final IApiClient _apiClient;
static const _prefix = '/api/v1/users';
UserRepositoryImpl(this._apiClient);
@override
Future<UserSummary> getById(String userId) async {
final response = await _apiClient.get<Map<String, dynamic>>(
'$_prefix/$userId',
);
final user = response.data;
if (user == null) {
throw StateError('Invalid getById response: empty payload');
}
return UserSummary.fromJson(user);
}
@override
Future<UserSummary> getMe() async {
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/me');
final user = response.data;
if (user == null) {
throw StateError('Invalid getMe response: empty payload');
}
return UserSummary.fromJson(user);
}
}
@@ -0,0 +1,112 @@
import '../../core/network/i_api_client.dart';
import '../cache/cache_invalidator.dart';
import '../repositories/models/schedule_item_model.dart';
class CalendarService {
static const _prefix = '/api/v1/schedule-items';
final IApiClient _apiClient;
final CacheInvalidator _invalidator;
CalendarService({
required IApiClient apiClient,
required CacheInvalidator invalidator,
}) : _apiClient = apiClient,
_invalidator = invalidator;
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
final start = DateTime(date.year, date.month, date.day);
final end = DateTime(date.year, date.month, date.day, 23, 59, 59);
return getEventsForRange(start, end);
}
Future<List<ScheduleItemModel>> getEventsForRange(
DateTime start,
DateTime end,
) async {
final startParam = Uri.encodeQueryComponent(
start.toUtc().toIso8601String(),
);
final endParam = Uri.encodeQueryComponent(end.toUtc().toIso8601String());
final response = await _apiClient.get<List<dynamic>>(
'$_prefix?start_at=$startParam&end_at=$endParam',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getEventsForRange response: empty payload');
}
return data
.map((item) => item as Map<String, dynamic>)
.map(ScheduleItemModel.fromJson)
.toList(growable: false);
}
Future<ScheduleItemModel> getEventById(String id) async {
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
final data = response.data;
if (data == null) {
throw StateError('Invalid getEventById response: empty payload');
}
return ScheduleItemModel.fromJson(data);
}
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
final response = await _apiClient.post<Map<String, dynamic>>(
_prefix,
data: event.toCreateJson(),
);
final data = response.data;
if (data == null) {
throw StateError('Invalid addEvent response: empty payload');
}
final created = ScheduleItemModel.fromJson(data);
_invalidateEventCache(created);
return created;
}
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
final response = await _apiClient.patch<Map<String, dynamic>>(
'$_prefix/${event.id}',
data: event.toUpdateJson(),
);
final data = response.data;
if (data == null) {
throw StateError('Invalid updateEvent response: empty payload');
}
final updated = ScheduleItemModel.fromJson(data);
_invalidateEventCache(updated);
return updated;
}
Future<ScheduleItemModel> archiveEvent(String id) async {
final event = await getEventById(id);
final updatedEvent = await updateEvent(
event.copyWith(status: ScheduleStatus.archived),
);
_invalidateEventCache(updatedEvent);
return updatedEvent;
}
Future<void> deleteEvent(String id) async {
final event = await getEventById(id);
_invalidateEventCache(event);
await _apiClient.delete<void>('$_prefix/$id');
}
void _invalidateEventCache(ScheduleItemModel event) {
var current = DateTime(
event.startAt.year,
event.startAt.month,
event.startAt.day,
);
final end = DateTime(
event.endAt?.year ?? event.startAt.year,
event.endAt?.month ?? event.startAt.month,
event.endAt?.day ?? event.startAt.day,
);
while (!current.isAfter(end)) {
_invalidator.invalidateCalendarDay(current);
current = current.add(const Duration(days: 1));
}
}
}
@@ -0,0 +1,20 @@
import '../../core/storage/app_preferences.dart';
import '../models/reminder_payload.dart';
class IOSNotificationPayloadBridge {
final AppPreferences _prefs;
IOSNotificationPayloadBridge(this._prefs);
ReminderPayload? getPendingPayload() {
return _prefs.pendingNotificationPayload;
}
Future<void> setPendingPayload(ReminderPayload payload) {
return _prefs.setPendingNotificationPayload(payload);
}
Future<void> clearPendingPayload() {
return _prefs.clearPendingNotificationPayload();
}
}
@@ -0,0 +1,307 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
import '../../core/l10n/l10n.dart';
import '../models/reminder_payload.dart';
import '../repositories/models/schedule_item_model.dart';
import 'reminder_notification_callbacks.dart';
class LocalNotificationService {
final FlutterLocalNotificationsPlugin _plugin;
bool _initialized = false;
LocalNotificationService({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
Future<void> initialize() async {
if (_initialized) {
return;
}
tz_data.initializeTimeZones();
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
final settings = InitializationSettings(android: android, iOS: ios);
await _plugin.initialize(
settings,
onDidReceiveNotificationResponse:
ReminderNotificationCallbacks.onForegroundResponse,
onDidReceiveBackgroundNotificationResponse:
reminderNotificationTapBackground,
);
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.requestNotificationsPermission();
await androidImpl?.requestExactAlarmsPermission();
await androidImpl?.requestFullScreenIntentPermission();
final iosImpl = _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
await iosImpl?.requestPermissions(alert: true, badge: true, sound: true);
_initialized = true;
}
Future<void> upsertEventReminder(ScheduleItemModel event) async {
await initialize();
if (event.status != ScheduleStatus.active ||
event.metadata?.reminderMinutes == null) {
await cancelEventReminder(event.id);
return;
}
final now = DateTime.now();
final reminderMinutes = event.metadata?.reminderMinutes ?? 0;
final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
if (fireAt.isBefore(now)) {
await cancelEventReminder(event.id);
return;
}
await cancelEventReminder(event.id);
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
}
Future<void> scheduleReminderAt(
ScheduleItemModel event,
DateTime fireAt,
) async {
await initialize();
await cancelEventReminder(event.id);
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
}
Future<void> cancelEventReminder(String eventId) async {
await initialize();
final pending = await _plugin.pendingNotificationRequests();
for (final request in pending) {
final payload = _decodePayload(request.payload);
if (payload == null) {
continue;
}
if (payload.eventId == eventId ||
payload.aggregateIds.contains(eventId)) {
await _plugin.cancel(request.id);
}
}
await _plugin.cancel(_notificationIdForEvent(eventId));
}
Future<void> rebuildUpcomingReminders(
Iterable<ScheduleItemModel> events,
) async {
await initialize();
for (final event in events) {
await upsertEventReminder(event);
}
}
int _notificationIdForEvent(String eventId) {
return eventId.hashCode & 0x7fffffff;
}
int _notificationIdForEventCycle(
String eventId,
DateTime fireAt,
ReminderPayloadMode mode,
) {
final cycleMinute =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff;
}
Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidImpl == null) {
return AndroidScheduleMode.exactAllowWhileIdle;
}
final canScheduleExact =
await androidImpl.canScheduleExactNotifications() ?? false;
return canScheduleExact
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle;
}
NotificationDetails _buildNotificationDetails(DateTime fireAt) {
return NotificationDetails(
android: AndroidNotificationDetails(
'calendar_alarm_channel_v2',
L10n.current.notificationChannelName,
channelDescription: L10n.current.notificationChannelDescription,
importance: Importance.max,
priority: Priority.max,
category: AndroidNotificationCategory.alarm,
audioAttributesUsage: AudioAttributesUsage.alarm,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]),
timeoutAfter: 30000,
autoCancel: true,
groupKey: _getGroupKey(fireAt),
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
presentBadge: true,
threadIdentifier: _getThreadIdentifier(fireAt),
),
);
}
String _getThreadIdentifier(DateTime fireAt) {
final bucket =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return 'calendar_reminder_$bucket';
}
String _getGroupKey(DateTime fireAt) {
final bucket =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return 'com.socialapp.calendar.$bucket';
}
Future<void> _scheduleSingleReminder({
required ScheduleItemModel event,
required DateTime fireAt,
}) async {
final notificationId = _notificationIdForEventCycle(
event.id,
fireAt,
ReminderPayloadMode.single,
);
final payload = ReminderPayload(
eventId: event.id,
title: event.title,
startAt: event.startAt,
endAt: event.endAt,
timezone: event.timezone,
location: event.metadata?.location,
notes: event.metadata?.notes,
color: event.metadata?.color,
mode: ReminderPayloadMode.single,
fireTimeBucket:
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds,
version: 1,
);
final details = _buildNotificationDetails(fireAt);
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
final mode = await _resolveAndroidScheduleMode();
try {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: mode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
} catch (_) {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
}
Future<void> _scheduleRemindersFrom({
required ScheduleItemModel event,
required DateTime firstFireAt,
}) async {
final endAt = event.endAt;
var cursor = firstFireAt;
if (endAt == null) {
await _scheduleSingleReminder(event: event, fireAt: cursor);
return;
}
while (cursor.isBefore(endAt)) {
await _scheduleSingleReminder(event: event, fireAt: cursor);
cursor = cursor.add(const Duration(minutes: 10));
}
}
ReminderPayload? _decodePayload(String? raw) {
if (raw == null || raw.isEmpty) {
return null;
}
try {
return ReminderPayload.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) {
final when = reminderMinutes == 0
? L10n.current.notificationStartsNow
: L10n.current.notificationStartsInMinutes(reminderMinutes);
final location = event.metadata?.location;
final notes = event.metadata?.notes;
final buffer = StringBuffer(when);
if (location != null && location.isNotEmpty) {
buffer.write('\n${L10n.current.notificationLocation(location)}');
}
if (notes != null && notes.isNotEmpty) {
final preview = notes.length > 30
? '${notes.substring(0, 30)}...'
: notes;
buffer.write('\n${L10n.current.notificationNotes(preview)}');
}
return buffer.toString();
}
Future<void> handleNotificationResponse(NotificationResponse response) async {
final payloadRaw = response.payload;
if (payloadRaw == null || payloadRaw.isEmpty) {
return;
}
ReminderPayload payload;
try {
payload = ReminderPayload.fromJson(
Map<String, dynamic>.from(jsonDecode(payloadRaw) as Map),
);
} catch (_) {
debugPrint('failed to handle reminder notification response');
return;
}
ReminderNotificationCallbacks.onNotificationPayloadReceived?.call(payload);
}
}
@@ -0,0 +1,146 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/storage/app_preferences.dart';
import '../models/reminder_payload.dart';
typedef ReminderNotificationResponseHandler =
Future<void> Function(NotificationResponse response);
class ReminderNotificationCallbacks {
static ReminderNotificationResponseHandler? _responseHandler;
static Future<void> _pendingStorageLock = Future<void>.value();
static void Function(ReminderPayload)? onNotificationPayloadReceived;
static AppPreferences? _appPreferences;
static Future<AppPreferences> _resolvePrefs() async {
final prefs = _appPreferences;
if (prefs != null) {
return prefs;
}
final sharedPreferences = await SharedPreferences.getInstance();
final fallback = AppPreferences(sharedPreferences);
_appPreferences = fallback;
return fallback;
}
static void setAppPreferences(AppPreferences prefs) {
_appPreferences = prefs;
}
@visibleForTesting
static Future<void> resetForTest() async {
_responseHandler = null;
_pendingStorageLock = Future<void>.value();
final prefs = await _resolvePrefs();
await prefs.clearPendingNotifications();
}
static Future<void> bindResponseHandler(
ReminderNotificationResponseHandler handler,
) async {
_responseHandler = handler;
await _drainPendingResponses();
}
static Future<void> onForegroundResponse(
NotificationResponse response,
) async {
final handler = _responseHandler;
if (handler == null) {
await _enqueuePendingResponse(response);
return;
}
try {
await handler(response);
} catch (_) {
await _enqueuePendingResponse(response);
}
}
static Future<void> onBackgroundResponse(
NotificationResponse response,
) async {
final handler = _responseHandler;
if (handler == null) {
await _enqueuePendingResponse(response);
return;
}
try {
await handler(response);
} catch (_) {
await _enqueuePendingResponse(response);
}
}
static Future<T> _withPendingStorageLock<T>(Future<T> Function() operation) {
final completer = Completer<void>();
final waitForTurn = _pendingStorageLock;
_pendingStorageLock = waitForTurn.then((_) => completer.future);
return waitForTurn.then((_) => operation()).whenComplete(() {
if (!completer.isCompleted) {
completer.complete();
}
});
}
static Future<void> _enqueuePendingResponse(
NotificationResponse response,
) async {
final prefs = await _resolvePrefs();
await _withPendingStorageLock(() async {
final current = prefs.pendingNotifications;
await prefs.setPendingNotifications([
...current,
NotificationResponse(
id: response.id,
actionId: response.actionId,
payload: response.payload,
input: response.input,
notificationResponseType: response.notificationResponseType,
),
]);
});
}
static Future<void> _drainPendingResponses() async {
final handler = _responseHandler;
if (handler == null) {
return;
}
final prefs = await _resolvePrefs();
await _withPendingStorageLock(() async {
final pending = prefs.pendingNotifications;
if (pending.isEmpty) {
return;
}
final remaining = <NotificationResponse>[];
for (final response in pending) {
try {
await handler(response);
} catch (_) {
remaining.add(response);
}
}
if (remaining.isEmpty) {
await prefs.clearPendingNotifications();
return;
}
await prefs.setPendingNotifications(remaining);
});
}
}
@pragma('vm:entry-point')
Future<void> reminderNotificationTapBackground(
NotificationResponse response,
) async {
await ReminderNotificationCallbacks.onBackgroundResponse(response);
}