187 lines
6.1 KiB
Dart
187 lines
6.1 KiB
Dart
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');
|
|
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);
|
|
|
|
// 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, ref);
|
|
});
|
|
|
|
class UserOfficesController {
|
|
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 {
|
|
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 {
|
|
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);
|
|
}
|
|
}
|