Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+370 -62
View File
@@ -1,17 +1,20 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/announcement.dart';
import '../models/announcement_comment.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
import 'notifications_provider.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
import '../utils/app_time.dart';
/// Maximum rows fetched per poll. Announcements are lower-volume than tasks,
/// so 100 is sufficient; adjust if orgs routinely exceed this.
/// Maximum rows fetched per poll.
const int kAnnouncementsPageLimit = 100;
/// Thrown when an announcement or comment is saved successfully but
@@ -24,6 +27,26 @@ class AnnouncementNotificationException implements Exception {
String toString() => message;
}
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// Announcements created offline that haven't been confirmed by Supabase yet.
final offlinePendingAnnouncementsProvider =
StateProvider<List<Announcement>>((ref) => const []);
/// Field-level updates to existing announcements made offline, keyed by id.
final offlinePendingAnnouncementUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
/// Comments added offline, not yet confirmed by Supabase.
final offlinePendingAnnouncementCommentsProvider =
StateProvider<List<AnnouncementComment>>((ref) => const []);
// ---------------------------------------------------------------------------
// Read providers
// ---------------------------------------------------------------------------
/// Streams all announcements visible to the current user (RLS filtered).
final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
final userId = ref.watch(currentUserIdProvider);
@@ -31,6 +54,53 @@ final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
final client = ref.watch(supabaseClientProvider);
final pendingNew = ref.watch(offlinePendingAnnouncementsProvider);
final pendingUpdates = ref.watch(offlinePendingAnnouncementUpdatesProvider);
List<Announcement> applyPending(List<Announcement> rows) {
final byId = {for (final r in rows) r.id: r};
pendingUpdates.forEach((id, fields) {
final existing = byId[id];
if (existing == null) return;
byId[id] = Announcement(
id: existing.id,
authorId: existing.authorId,
title: (fields['title'] as String?) ?? existing.title,
body: (fields['body'] as String?) ?? existing.body,
visibleRoles: (fields['visible_roles'] as List<String>?) ??
existing.visibleRoles,
isTemplate: (fields['is_template'] as bool?) ?? existing.isTemplate,
templateId: fields.containsKey('template_id')
? fields['template_id'] as String?
: existing.templateId,
createdAt: existing.createdAt,
updatedAt: fields.containsKey('updated_at')
? AppTime.parse(fields['updated_at'] as String)
: existing.updatedAt,
bannerEnabled:
(fields['banner_enabled'] as bool?) ?? existing.bannerEnabled,
bannerShowAt: fields.containsKey('banner_show_at')
? (fields['banner_show_at'] != null
? AppTime.parse(fields['banner_show_at'] as String)
: null)
: existing.bannerShowAt,
bannerHideAt: fields.containsKey('banner_hide_at')
? (fields['banner_hide_at'] != null
? AppTime.parse(fields['banner_hide_at'] as String)
: null)
: existing.bannerHideAt,
pushIntervalMinutes: fields.containsKey('push_interval_minutes')
? fields['push_interval_minutes'] as int?
: existing.pushIntervalMinutes,
);
});
for (final p in pendingNew) {
byId.putIfAbsent(p.id, () => p);
}
return byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
final wrapper = StreamRecoveryWrapper<Announcement>(
stream: client
.from('announcements')
@@ -47,10 +117,138 @@ final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
fromMap: Announcement.fromMap,
channelName: 'announcements',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<Announcement>();
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Announcement>(rows, tag: 'announcements'),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created announcements
final pendingList =
List<Announcement>.from(ref.read(offlinePendingAnnouncementsProvider));
if (pendingList.isNotEmpty) {
debugPrint(
'[announcementsProvider] reconnected — syncing ${pendingList.length} announcement(s)',
);
final synced = <String>[];
for (final a in pendingList) {
try {
await client.from('announcements').insert({
'id': a.id,
'author_id': a.authorId,
'title': a.title,
'body': a.body,
'visible_roles': a.visibleRoles,
'is_template': a.isTemplate,
'template_id': a.templateId,
'banner_enabled': a.bannerEnabled,
'banner_show_at': a.bannerShowAt?.toIso8601String(),
'banner_hide_at': a.bannerHideAt?.toIso8601String(),
'push_interval_minutes': a.pushIntervalMinutes,
});
synced.add(a.id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
synced.add(a.id);
} else {
debugPrint(
'[announcementsProvider] failed to sync id=${a.id}: $e',
);
}
}
}
if (synced.isNotEmpty) {
final remaining = ref
.read(offlinePendingAnnouncementsProvider)
.where((a) => !synced.contains(a.id))
.toList();
ref.read(offlinePendingAnnouncementsProvider.notifier).state =
List.unmodifiable(remaining);
}
}
// 2. Replay offline announcement updates
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAnnouncementUpdatesProvider),
);
if (pendingUpdatesMap.isNotEmpty) {
debugPrint(
'[announcementsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
);
final syncedIds = <String>[];
for (final entry in pendingUpdatesMap.entries) {
try {
await client
.from('announcements')
.update(entry.value)
.eq('id', entry.key);
syncedIds.add(entry.key);
} catch (e) {
debugPrint(
'[announcementsProvider] failed to sync update id=${entry.key}: $e',
);
}
}
if (syncedIds.isNotEmpty) {
final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAnnouncementUpdatesProvider),
);
for (final id in syncedIds) { remaining.remove(id); }
ref.read(offlinePendingAnnouncementUpdatesProvider.notifier).state =
remaining;
}
}
// 3. Replay offline comments
final pendingComments = List<AnnouncementComment>.from(
ref.read(offlinePendingAnnouncementCommentsProvider),
);
if (pendingComments.isNotEmpty) {
debugPrint(
'[announcementsProvider] reconnected — syncing ${pendingComments.length} comment(s)',
);
final synced = <String>[];
for (final c in pendingComments) {
try {
await client.from('announcement_comments').insert({
'id': c.id,
'announcement_id': c.announcementId,
'author_id': c.authorId,
'body': c.body,
});
synced.add(c.id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
synced.add(c.id);
} else {
debugPrint(
'[announcementsProvider] failed to sync comment id=${c.id}: $e',
);
}
}
}
if (synced.isNotEmpty) {
final remaining = ref
.read(offlinePendingAnnouncementCommentsProvider)
.where((c) => !synced.contains(c.id))
.toList();
ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state =
List.unmodifiable(remaining);
}
}
});
return wrapper.stream.map((result) => applyPending(result.data));
});
/// Streams comments for a specific announcement.
@@ -61,6 +259,20 @@ final announcementCommentsProvider =
) {
final client = ref.watch(supabaseClientProvider);
final pendingComments =
ref.watch(offlinePendingAnnouncementCommentsProvider);
List<AnnouncementComment> applyPending(List<AnnouncementComment> rows) {
final byId = {for (final r in rows) r.id: r};
for (final c in pendingComments) {
if (c.announcementId == announcementId) {
byId.putIfAbsent(c.id, () => c);
}
}
return byId.values.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
final wrapper = StreamRecoveryWrapper<AnnouncementComment>(
stream: client
.from('announcement_comments')
@@ -78,37 +290,51 @@ final announcementCommentsProvider =
fromMap: AnnouncementComment.fromMap,
channelName: 'announcement_comments_$announcementId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<AnnouncementComment>();
final filtered = all
.where((c) => c.announcementId == announcementId)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>(
rows,
tag: 'announcement_comments'),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
return wrapper.stream.map((result) => applyPending(result.data));
});
/// Active banner announcements for the current user.
/// Returns only non-template announcements whose banner is currently in its
/// active time window ([Announcement.isBannerActive]).
final activeBannerAnnouncementsProvider = Provider<List<Announcement>>((ref) {
final all = ref.watch(announcementsProvider).valueOrNull ?? [];
return all.where((a) => !a.isTemplate && a.isBannerActive).toList();
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final announcementsControllerProvider =
Provider<AnnouncementsController>((ref) {
final client = ref.watch(supabaseClientProvider);
final notifCtrl = ref.watch(notificationsControllerProvider);
return AnnouncementsController(client, notifCtrl);
return AnnouncementsController(client, notifCtrl, ref);
});
class AnnouncementsController {
AnnouncementsController(this._client, this._notifCtrl);
AnnouncementsController(this._client, this._notifCtrl, [this._ref]);
// _client is declared dynamic to allow test doubles that mimic only the
// subset of methods used by this class. In production it is a SupabaseClient.
// _client is declared dynamic to allow test doubles.
// ignore: avoid_dynamic_calls
final dynamic _client;
final dynamic _notifCtrl;
final Ref? _ref;
/// Create a new announcement and send push notifications to target users.
/// Offline: queues a local row and returns silently.
Future<void> createAnnouncement({
required String title,
required String body,
@@ -120,10 +346,14 @@ class AnnouncementsController {
DateTime? bannerHideAt,
int? pushIntervalMinutes,
}) async {
final authorId = _client.auth.currentUser?.id;
final authorId = _client.auth.currentUser?.id as String?;
if (authorId == null) return;
final id = const Uuid().v4();
final now = AppTime.now();
final row = {
'id': id,
'author_id': authorId,
'title': title,
'body': body,
@@ -136,23 +366,39 @@ class AnnouncementsController {
'push_interval_minutes': pushIntervalMinutes,
};
final result = await _client
.from('announcements')
.insert(row)
.select('id')
.single();
final announcementId = result['id'] as String;
String? announcementId;
try {
final result = await _client
.from('announcements')
.insert(row)
.select('id')
.single();
announcementId = result['id'] as String;
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingAnnouncement(
Announcement(
id: id,
authorId: authorId,
title: title,
body: body,
visibleRoles: visibleRoles,
isTemplate: isTemplate,
templateId: templateId,
createdAt: now,
updatedAt: now,
bannerEnabled: bannerEnabled,
bannerShowAt: bannerShowAt,
bannerHideAt: bannerHideAt,
pushIntervalMinutes: pushIntervalMinutes,
),
);
return;
}
// Don't send notifications for templates (they are drafts for reuse)
if (isTemplate) return;
// Skip the one-time creation push when a scheduled banner push is
// configured. The banner scheduler will send the first push on its own
// interval, so firing an extra push here would result in two back-to-back
// notifications for the same announcement.
if (bannerEnabled && pushIntervalMinutes != null) return;
// Query users whose role matches visible_roles, excluding the author
try {
final profiles = await _client
.from('profiles')
@@ -160,9 +406,8 @@ class AnnouncementsController {
.inFilter('role', visibleRoles);
final userIds = (profiles as List)
.map((p) => p['id'] as String)
.where((id) => id != authorId)
.where((uid) => uid != authorId)
.toList();
if (userIds.isEmpty) return;
await _notifCtrl.createNotification(
@@ -183,7 +428,7 @@ class AnnouncementsController {
}
}
/// Update an existing announcement.
/// Update an existing announcement. Offline: queues the field patch.
Future<void> updateAnnouncement({
required String id,
required String title,
@@ -221,11 +466,16 @@ class AnnouncementsController {
} else if (clearPushInterval) {
payload['push_interval_minutes'] = null;
}
await _client.from('announcements').update(payload).eq('id', id);
try {
await _client.from('announcements').update(payload).eq('id', id);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueAnnouncementUpdate(id, payload);
}
}
/// Update only the banner settings on an existing announcement.
/// Intended for the "Manage Banner" popup available to the poster and admins.
/// Update only the banner settings. Offline: queues the field patch.
Future<void> updateBannerSettings({
required String id,
required bool bannerEnabled,
@@ -233,25 +483,37 @@ class AnnouncementsController {
DateTime? bannerHideAt,
int? pushIntervalMinutes,
}) async {
await _client.from('announcements').update({
final payload = <String, dynamic>{
'banner_enabled': bannerEnabled,
'banner_show_at': bannerShowAt?.toUtc().toIso8601String(),
'banner_hide_at': bannerHideAt?.toUtc().toIso8601String(),
'push_interval_minutes': pushIntervalMinutes,
'updated_at': AppTime.nowUtc().toIso8601String(),
}).eq('id', id);
};
try {
await _client.from('announcements').update(payload).eq('id', id);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueAnnouncementUpdate(id, payload);
}
}
/// Immediately stops a banner by setting [banner_hide_at] to now.
/// Usable by the poster or an admin.
/// Immediately stops a banner by setting banner_hide_at to now.
/// Offline: queues the field patch.
Future<void> dismissBanner(String id) async {
await _client.from('announcements').update({
final payload = <String, dynamic>{
'banner_hide_at': AppTime.nowUtc().toIso8601String(),
'updated_at': AppTime.nowUtc().toIso8601String(),
}).eq('id', id);
};
try {
await _client.from('announcements').update(payload).eq('id', id);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueAnnouncementUpdate(id, payload);
}
}
/// Delete an announcement.
/// Delete an announcement. Online-only — destructive actions are not queued.
Future<void> deleteAnnouncement(String id) async {
await _client.from('announcements').delete().eq('id', id);
}
@@ -264,27 +526,41 @@ class AnnouncementsController {
.eq('id', templateId)
.maybeSingle();
if (data == null) return null;
return Announcement.fromMap(data);
return Announcement.fromMap(data as Map<String, dynamic>);
}
/// Add a comment to an announcement.
/// Notifies the announcement author and all previous commenters.
/// Add a comment. Offline: queues the comment locally.
Future<void> addComment({
required String announcementId,
required String body,
}) async {
final authorId = _client.auth.currentUser?.id;
final authorId = _client.auth.currentUser?.id as String?;
if (authorId == null) return;
await _client.from('announcement_comments').insert({
'announcement_id': announcementId,
'author_id': authorId,
'body': body,
});
final id = const Uuid().v4();
try {
await _client.from('announcement_comments').insert({
'id': id,
'announcement_id': announcementId,
'author_id': authorId,
'body': body,
});
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingComment(
AnnouncementComment(
id: id,
announcementId: announcementId,
authorId: authorId,
body: body,
createdAt: AppTime.now(),
),
);
return;
}
// Notify announcement author + previous commenters
try {
// Get the announcement author and title
final announcement = await _client
.from('announcements')
.select('author_id, title')
@@ -296,23 +572,18 @@ class AnnouncementsController {
final announcementTitle =
announcement['title'] as String? ?? 'an announcement';
// Get all unique commenters on this announcement
final comments = await _client
.from('announcement_comments')
.select('author_id')
.eq('announcement_id', announcementId);
final commenterIds = (comments as List)
.map((c) => c['author_id'] as String)
.toSet();
final commenterIds =
(comments as List).map((c) => c['author_id'] as String).toSet();
// Combine author + commenters, exclude self
final notifyIds = <String>{announcementAuthorId, ...commenterIds}
.where((id) => id != authorId)
.where((uid) => uid != authorId)
.toList();
if (notifyIds.isEmpty) return;
// Fetch commenter's display name for a human-readable push body
final commenterData = await _client
.from('profiles')
.select('full_name')
@@ -340,10 +611,9 @@ class AnnouncementsController {
}
/// Re-sends push notifications for an existing announcement.
/// Intended for use by admins or the announcement author.
Future<void> resendAnnouncementNotification(
Announcement announcement) async {
final actorId = _client.auth.currentUser?.id;
final actorId = _client.auth.currentUser?.id as String?;
if (actorId == null) return;
if (announcement.visibleRoles.isEmpty) return;
@@ -354,9 +624,8 @@ class AnnouncementsController {
.inFilter('role', announcement.visibleRoles);
final userIds = (profiles as List)
.map((p) => p['id'] as String)
.where((id) => id != actorId)
.where((uid) => uid != actorId)
.toList();
if (userIds.isEmpty) return;
await _notifCtrl.createNotification(
@@ -379,8 +648,47 @@ class AnnouncementsController {
}
}
/// Delete a comment.
/// Delete a comment. Online-only — destructive actions are not queued.
Future<void> deleteComment(String id) async {
await _client.from('announcement_comments').delete().eq('id', id);
}
// ---------------------------------------------------------------------------
// Queue helpers
// ---------------------------------------------------------------------------
void _queuePendingAnnouncement(Announcement announcement) {
final ref = _ref;
if (ref == null) return;
final current = List<Announcement>.from(
ref.read(offlinePendingAnnouncementsProvider),
);
current.add(announcement);
ref.read(offlinePendingAnnouncementsProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<Announcement>([announcement], tag: 'offline_announcement');
}
void _queueAnnouncementUpdate(String id, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingAnnouncementUpdatesProvider),
);
current[id] = {...?current[id], ...fields};
ref.read(offlinePendingAnnouncementUpdatesProvider.notifier).state = current;
}
void _queuePendingComment(AnnouncementComment comment) {
final ref = _ref;
if (ref == null) return;
final current = List<AnnouncementComment>.from(
ref.read(offlinePendingAnnouncementCommentsProvider),
);
current.add(comment);
ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<AnnouncementComment>([comment],
tag: 'offline_announcement_comment');
}
}