Files
tasq/lib/providers/user_offices_provider.dart
T
2026-04-27 06:20:39 +08:00

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);
}
}