Offline Support
This commit is contained in:
@@ -1,63 +1,186 @@
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../models/user_office.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'connectivity_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Queued assign/remove operations made offline.
|
||||
/// Each entry: {'op': 'assign'|'remove', 'user_id': ..., 'office_id': ...}
|
||||
final offlinePendingUserOfficeOpsProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final pendingOps = ref.watch(offlinePendingUserOfficeOpsProvider);
|
||||
|
||||
List<UserOffice> applyPending(List<UserOffice> rows) {
|
||||
if (pendingOps.isEmpty) return rows;
|
||||
var result = List<UserOffice>.from(rows);
|
||||
for (final op in pendingOps) {
|
||||
final userId = op['user_id'] as String;
|
||||
final officeId = op['office_id'] as String;
|
||||
if (op['op'] == 'assign') {
|
||||
final exists = result.any(
|
||||
(r) => r.userId == userId && r.officeId == officeId,
|
||||
);
|
||||
if (!exists) result.add(UserOffice(userId: userId, officeId: officeId));
|
||||
} else if (op['op'] == 'remove') {
|
||||
result.removeWhere(
|
||||
(r) => r.userId == userId && r.officeId == officeId,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<UserOffice>(
|
||||
stream: client
|
||||
.from('user_offices')
|
||||
.stream(primaryKey: ['user_id', 'office_id'])
|
||||
.order('created_at'),
|
||||
onPollData: () async {
|
||||
final data = await client
|
||||
.from('user_offices')
|
||||
.select()
|
||||
.order('created_at');
|
||||
final data = await client.from('user_offices').select().order('created_at');
|
||||
return data.map(UserOffice.fromMap).toList();
|
||||
},
|
||||
fromMap: UserOffice.fromMap,
|
||||
channelName: 'user_offices',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final rows = await CacheWarmer.readCachedUserOffices();
|
||||
return applyPending(rows.map(UserOffice.fromMap).toList());
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
final pending = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingUserOfficeOpsProvider),
|
||||
);
|
||||
if (pending.isEmpty) return;
|
||||
debugPrint(
|
||||
'[userOfficesProvider] reconnected — syncing ${pending.length} office op(s)',
|
||||
);
|
||||
final synced = <int>[];
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
final op = pending[i];
|
||||
final userId = op['user_id'] as String;
|
||||
final officeId = op['office_id'] as String;
|
||||
try {
|
||||
if (op['op'] == 'assign') {
|
||||
await client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
} else {
|
||||
await client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
}
|
||||
synced.add(i);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
synced.add(i);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[userOfficesProvider] failed to sync op $i: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
if (!synced.contains(i)) remaining.add(pending[i]);
|
||||
}
|
||||
ref.read(offlinePendingUserOfficeOpsProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final userOfficesControllerProvider = Provider<UserOfficesController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return UserOfficesController(client);
|
||||
return UserOfficesController(client, ref);
|
||||
});
|
||||
|
||||
class UserOfficesController {
|
||||
UserOfficesController(this._client);
|
||||
UserOfficesController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Assign a user to an office. Offline: queues the operation.
|
||||
Future<void> assignUserOffice({
|
||||
required String userId,
|
||||
required String officeId,
|
||||
}) async {
|
||||
await _client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
try {
|
||||
await _client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueOp('assign', userId, officeId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a user from an office. Offline: queues the operation.
|
||||
Future<void> removeUserOffice({
|
||||
required String userId,
|
||||
required String officeId,
|
||||
}) async {
|
||||
await _client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
try {
|
||||
await _client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueOp('remove', userId, officeId);
|
||||
}
|
||||
}
|
||||
|
||||
void _queueOp(String op, String userId, String officeId) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingUserOfficeOpsProvider),
|
||||
);
|
||||
// Dedupe: collapse redundant assign/remove pairs for the same user+office
|
||||
current.removeWhere(
|
||||
(e) => e['user_id'] == userId && e['office_id'] == officeId,
|
||||
);
|
||||
current.add({'op': op, 'user_id': userId, 'office_id': officeId});
|
||||
ref.read(offlinePendingUserOfficeOpsProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user