import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'package:uuid/uuid.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; import '../brick/repository.dart'; import '../models/task.dart'; import '../models/task_activity_log.dart'; import 'connectivity_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/task_assignment.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'tickets_provider.dart'; import 'user_offices_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; import '../utils/app_time.dart'; /// Maximum rows fetched in a single seed/poll request. /// If the server returns exactly this many rows the dataset may be truncated; /// a debug warning is logged so it is caught during development. const int kTaskPageLimit = 200; String _stableJson(dynamic value) { if (value is Map) { final keys = value.keys.map((k) => k.toString()).toList()..sort(); final entries = {}; for (final key in keys) { entries[key] = _stableJson(value[key]); } return jsonEncode(entries); } if (value is List) { return jsonEncode(value.map(_stableJson).toList(growable: false)); } return jsonEncode(value); } String _activityFingerprint(Map row) { final taskId = row['task_id']?.toString() ?? ''; final actorId = row['actor_id']?.toString() ?? ''; final actionType = row['action_type']?.toString() ?? ''; final meta = row['meta']; return '$taskId|$actorId|$actionType|${_stableJson(meta)}'; } /// Build a UI-display fingerprint for a log entry so that entries that would /// render identically are collapsed to a single row. /// /// For auto-save fields (filled_*), the actual text value changes every millisecond /// as the user types, creating hundreds of entries. By ignoring the text value and /// grouping strictly by "Task + Actor + Action + Visual Minute", all auto-save /// logs within the exact same minute collapse into a single display row. String _uiFingerprint(TaskActivityLog log) { const oncePerTask = {'created', 'started', 'completed'}; if (oncePerTask.contains(log.actionType)) { return '${log.taskId}|${log.actionType}'; } // Pause/resume events are sequence-sensitive for execution-time calculations. // Keep each row distinct to avoid collapsing multiple toggles in the same minute. if (log.actionType == 'paused' || log.actionType == 'resumed') { return '${log.taskId}|${log.id}'; } // The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month, // day, hour, and minute ensures we collapse duplicates that look identical on screen. final visualMinute = '${log.createdAt.year}-${log.createdAt.month}-${log.createdAt.day}-${log.createdAt.hour}-${log.createdAt.minute}'; if (log.actionType.startsWith('filled_')) { // DO NOT include the meta/value here! If the user types "A", "AB", "ABC" in the // same minute, including the value would make them mathematically "unique" and // bypass the deduplication. Grouping by time+action solves the auto-save spam. return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$visualMinute'; } // For other actions, we also include the normalized meta to be safe. final metaStr = _normaliseMeta(log.meta); return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$metaStr|$visualMinute'; } List _dedupeActivityLogs(List logs) { if (logs.length < 2) return logs; // Sort newest first so we always keep the most recent copy of a duplicate. final sorted = [...logs]..sort((a, b) => b.createdAt.compareTo(a.createdAt)); final seen = {}; final deduped = []; for (final log in sorted) { final fp = _uiFingerprint(log); if (seen.add(fp)) { deduped.add(log); } } return deduped; } /// Normalise a meta value for comparison: treat null and empty-map /// identically, and sort map keys for stability. String _normaliseMeta(dynamic meta) { if (meta == null) return ''; final s = _stableJson(meta); return (s == 'null' || s == '{}') ? '' : s; } // Helper to insert activity log rows while sanitizing nulls and // avoiding exceptions from malformed payloads. Accepts either a Map // or a List. Future _insertActivityRows(dynamic client, dynamic rows) async { try { if (rows == null) return; if (rows is List) { final sanitized = rows .map((r) { if (r is Map) { final m = Map.from(r); m.removeWhere((k, v) => v == null); return m; } return null; }) .whereType>() .toList(); if (sanitized.isEmpty) return; final seen = {}; final dedupedBatch = >[]; for (final row in sanitized) { final fingerprint = _activityFingerprint(row); if (seen.add(fingerprint)) { dedupedBatch.add(row); } } if (dedupedBatch.isEmpty) return; await client.from('task_activity_logs').insert(dedupedBatch); } else if (rows is Map) { final m = Map.from(rows); m.removeWhere((k, v) => v == null); await client.from('task_activity_logs').insert(m); } } catch (e) { // Log for debugging but don't rethrow to avoid breaking caller flows try { debugPrint('[insertActivityRows] insert failed: $e'); } catch (_) {} } } /// Task query parameters for server-side pagination and filtering. class TaskQuery { /// Creates task query parameters. const TaskQuery({ this.offset = 0, this.limit = 50, this.searchQuery = '', this.officeId, this.status, this.taskNumber, this.dateRange, }); /// Offset for pagination. final int offset; /// Number of items per page (default: 50). final int limit; /// Full text search query. final String searchQuery; /// Filter by office ID. final String? officeId; /// Filter by status. final String? status; /// Filter by task number (partial match, case-insensitive). final String? taskNumber; /// Filter by date range. final DateTimeRange? dateRange; TaskQuery copyWith({ int? offset, int? limit, String? searchQuery, String? officeId, String? status, String? taskNumber, DateTimeRange? dateRange, }) { return TaskQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, searchQuery: searchQuery ?? this.searchQuery, officeId: officeId ?? this.officeId, status: status ?? this.status, taskNumber: taskNumber ?? this.taskNumber, dateRange: dateRange ?? this.dateRange, ); } } /// Builds the isolate payload from a list of [Task] objects and the current /// query/access context. Extracted so the initial REST seed and the realtime /// stream listener can share the same logic without duplication. Map _buildTaskPayload({ required List tasks, required bool isGlobal, required List allowedTicketIds, required List allowedOfficeIds, required TaskQuery query, }) { final rowsList = tasks .map( (task) => { 'id': task.id, 'task_number': task.taskNumber, 'office_id': task.officeId, 'ticket_id': task.ticketId, 'title': task.title, 'description': task.description, 'status': task.status, 'priority': task.priority, 'creator_id': task.creatorId, 'created_at': task.createdAt.toIso8601String(), 'started_at': task.startedAt?.toIso8601String(), 'completed_at': task.completedAt?.toIso8601String(), 'requested_by': task.requestedBy, 'noted_by': task.notedBy, 'received_by': task.receivedBy, 'queue_order': task.queueOrder, 'request_type': task.requestType, 'request_type_other': task.requestTypeOther, 'request_category': task.requestCategory, 'action_taken': task.actionTaken, 'cancellation_reason': task.cancellationReason, 'cancelled_at': task.cancelledAt?.toIso8601String(), 'it_job_printed': task.itJobPrinted, 'it_job_printed_at': task.itJobPrintedAt?.toIso8601String(), 'it_job_received_by_id': task.itJobReceivedById, }, ) .toList(); return { 'rows': rowsList, 'isGlobal': isGlobal, 'allowedTicketIds': allowedTicketIds, 'allowedOfficeIds': allowedOfficeIds, 'officeId': query.officeId, 'status': query.status, 'searchQuery': query.searchQuery, 'taskNumber': query.taskNumber, 'dateStart': query.dateRange?.start.millisecondsSinceEpoch, 'dateEnd': query.dateRange?.end.millisecondsSinceEpoch, }; } final tasksProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final profileAsync = ref.watch(currentProfileProvider); // Watch only the ticket ID set — prevents tasksProvider from rebuilding // when ticket *details* change (status, messages, etc.), only on ID changes. final ticketIds = ref.watch( ticketsProvider.select( (async) => async.valueOrNull?.map((t) => t.id).toSet() ?? const {}, ), ); final assignmentsAsync = ref.watch(userOfficesProvider); final query = ref.watch(tasksQueryProvider); final profile = profileAsync.valueOrNull; if (profile == null) { return Stream.value(const []); } final isGlobal = profile.role == 'admin' || profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; final allowedTicketIds = ticketIds.toList(); final allowedOfficeIds = assignmentsAsync.valueOrNull ?.where((a) => a.userId == profile.id) .map((a) => a.officeId) .toSet() .toList() ?? []; // For non-global users with no assigned offices/tickets, skip subscribing. if (!isGlobal && allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) { return Stream.value(const []); } // Wrap realtime stream with recovery logic final wrapper = StreamRecoveryWrapper( stream: client.from('tasks').stream(primaryKey: ['id']), onPollData: () async { // Apply server-side filters to reduce poll payload. // Role-based filtering and complex searches still run in the isolate. var q = client.from('tasks').select(); if (query.status != null) q = q.eq('status', query.status!); if (query.searchQuery.isNotEmpty) { q = q.or( 'title.ilike.%${query.searchQuery}%,' 'description.ilike.%${query.searchQuery}%', ); } final data = await q .order('created_at', ascending: false) .range(0, kTaskPageLimit - 1); return data.cast>().map(Task.fromMap).toList(); }, fromMap: Task.fromMap, channelName: 'tasks', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { if (!AppRepository.isConfigured) return []; try { final all = await AppRepository.instance.get( policy: OfflineFirstGetPolicy.localOnly, ); return {for (final t in all) t.id: t}.values.toList(); } catch (_) { return []; } }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'tasks'), ); ref.onDispose(wrapper.dispose); // When the device reconnects, directly INSERT any pending offline tasks into // Supabase rather than waiting for Brick's HTTP queue. The queue may be // blocked by stale entries that keep returning 504 (connection pool timeout), // preventing the newly-queued INSERT from ever replaying. This listener is a // reliable bypass: it fires exactly once on the false→true transition and // sends a fresh POST directly to the Supabase REST API. ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { // wasOnline is null on the first call after every provider rebuild, not the // value at setup time. Use != true so that both null and false trigger the // replay, matching the intent "we just came back online". if (wasOnline != true && isNowOnline) { final pending = ref.read(offlinePendingTasksProvider); final pendingUpdates = Map>.from( ref.read(offlinePendingTaskUpdatesProvider), ); final pendingTaskIds = pending.map((t) => t.id).toSet(); // ── 1. Sync offline-created tasks (INSERT) ────────────────────────── if (pending.isNotEmpty) { debugPrint( '[tasksProvider] reconnected — syncing ${pending.length} pending task(s)', ); } final failedTaskIds = {}; for (final task in pending) { try { final syncPayload = { 'id': task.id, 'title': task.title, 'description': task.description, }; if (task.officeId != null) syncPayload['office_id'] = task.officeId; if (task.ticketId != null) syncPayload['ticket_id'] = task.ticketId; if (task.requestType != null) { syncPayload['request_type'] = task.requestType; } if (task.requestTypeOther != null) { syncPayload['request_type_other'] = task.requestTypeOther; } if (task.requestCategory != null) { syncPayload['request_category'] = task.requestCategory; } // Merge any field edits made to this pending task while offline so // they are included in the INSERT rather than a follow-up PATCH. final accumulated = pendingUpdates[task.id]; if (accumulated != null && accumulated.isNotEmpty) { syncPayload.addAll(accumulated); } await client.from('tasks').insert(syncPayload); pendingUpdates.remove(task.id); // INSERT carried the edits debugPrint('[tasksProvider] synced pending task id=${task.id}'); } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { // Task already in DB — treat as success so it's cleared from pending. debugPrint( '[tasksProvider] pending task already in DB id=${task.id}', ); } else { failedTaskIds.add(task.id); debugPrint( '[tasksProvider] failed to sync pending task id=${task.id}: $e', ); } } } // Clear successfully synced pending tasks. Don't rely on the // tasks_list_screen listener alone — it only fires when that screen is // active, leaving the banner stuck on other screens indefinitely. if (pending.isNotEmpty) { final remaining = pending.where((t) => failedTaskIds.contains(t.id)).toList(); ref.read(offlinePendingTasksProvider.notifier).state = remaining; } // ── 2. Sync offline field edits for EXISTING tasks (PATCH) ────────── // pendingUpdates already had pending-task entries removed in step 1. // What remains are edits to tasks that were already in the DB. final updatesForExisting = pendingUpdates.entries .where((e) => !pendingTaskIds.contains(e.key)) .toList(); if (updatesForExisting.isNotEmpty) { debugPrint( '[tasksProvider] reconnected — syncing ${updatesForExisting.length} pending edit(s)', ); final synced = []; for (final entry in updatesForExisting) { try { // action_taken may be a string (Quill delta JSON); convert for DB. final dbPayload = Map.from(entry.value); if (dbPayload['action_taken'] is String) { try { dbPayload['action_taken'] = jsonDecode(dbPayload['action_taken'] as String); } catch (_) {} } await client.from('tasks').update(dbPayload).eq('id', entry.key); synced.add(entry.key); debugPrint( '[tasksProvider] synced pending edit for task id=${entry.key}', ); } catch (e) { debugPrint( '[tasksProvider] failed to sync edit for task id=${entry.key}: $e', ); } } // Clear successfully synced edits. if (synced.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingTaskUpdatesProvider), ); for (final id in synced) { remaining.remove(id); } ref.read(offlinePendingTaskUpdatesProvider.notifier).state = remaining; } } // ── 3. Sync offline-queued messages ──────────────────────────────── final pendingMsgs = Map>>.from( ref.read(offlinePendingMessagesProvider), ); if (pendingMsgs.isNotEmpty) { debugPrint( '[tasksProvider] reconnected — syncing messages for ' '${pendingMsgs.length} task(s)', ); final syncedTaskIds = []; for (final entry in pendingMsgs.entries) { bool allOk = true; for (final msgPayload in entry.value) { try { await client.from('ticket_messages').insert(msgPayload); } catch (e) { debugPrint( '[tasksProvider] failed to sync message for task ' '${entry.key}: $e', ); allOk = false; } } if (allOk) syncedTaskIds.add(entry.key); } if (syncedTaskIds.isNotEmpty) { final remaining = Map>>.from( ref.read(offlinePendingMessagesProvider), ); for (final id in syncedTaskIds) { remaining.remove(id); } ref.read(offlinePendingMessagesProvider.notifier).state = remaining; } } // ── 4. Sync offline assignment replacements ───────────────────────── final pendingAssigns = Map>.from( ref.read(offlinePendingAssignmentsProvider), ); if (pendingAssigns.isNotEmpty) { debugPrint( '[tasksProvider] reconnected — syncing assignments for ' '${pendingAssigns.length} task(s)', ); final syncedAssignIds = []; for (final entry in pendingAssigns.entries) { try { await client .from('task_assignments') .delete() .eq('task_id', entry.key); if (entry.value.isNotEmpty) { final rows = entry.value .map((uid) => {'task_id': entry.key, 'user_id': uid}) .toList(); await client.from('task_assignments').insert(rows); } syncedAssignIds.add(entry.key); debugPrint( '[tasksProvider] synced assignments for task ${entry.key}', ); } catch (e) { debugPrint( '[tasksProvider] failed to sync assignments for task ' '${entry.key}: $e', ); } } if (syncedAssignIds.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingAssignmentsProvider), ); for (final id in syncedAssignIds) { remaining.remove(id); } ref.read(offlinePendingAssignmentsProvider.notifier).state = remaining; } } // ── 5. Replay queued activity logs ────────────────────────────────── final pendingLogs = List>.from( ref.read(offlinePendingActivityLogsProvider), ); if (pendingLogs.isNotEmpty) { debugPrint( '[tasksProvider] reconnected — syncing ${pendingLogs.length} activity log(s)', ); try { await client.from('task_activity_logs').insert(pendingLogs); ref.read(offlinePendingActivityLogsProvider.notifier).state = const []; // Clear the typed items provider so merged offline logs no longer // appear as extras once the server copy is confirmed. ref.read(offlinePendingActivityLogItemsProvider.notifier).state = const []; } catch (e) { debugPrint('[tasksProvider] failed to sync activity logs: $e'); } } } }); var lastResultHash = ''; Timer? debounceTimer; // broadcast() so Riverpod and any other listener can both receive events. final controller = StreamController>.broadcast(); void emitDebounced(List tasks) { debounceTimer?.cancel(); debounceTimer = Timer(const Duration(milliseconds: 150), () { if (!controller.isClosed) controller.add(tasks); }); } ref.onDispose(() { debounceTimer?.cancel(); controller.close(); }); // ── Immediate REST seed ─────────────────────────────────────────────────── // Fire a one-shot HTTP fetch right now so the UI can render before the // WebSocket realtime channel is fully established. Eliminates loading delay // on web and initial flash on mobile. Hash check prevents a duplicate // rebuild if both the seed and the realtime stream arrive with the same data. unawaited( Future(() async { // When offline, seed from Brick local SQLite cache instead of network. if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) { try { final all = await AppRepository.instance.get( policy: OfflineFirstGetPolicy.localOnly, ); final cached = {for (final t in all) t.id: t}.values.toList(); debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache'); final payload = _buildTaskPayload( tasks: cached, isGlobal: isGlobal, allowedTicketIds: allowedTicketIds, allowedOfficeIds: allowedOfficeIds, query: query, ); final processed = await compute(_processTasksInIsolate, payload); final tasks = (processed as List) .cast>() .map(Task.fromMap) .toList(); final hash = tasks.fold('', (h, t) => '$h${t.hashCode}'); if (!controller.isClosed && hash != lastResultHash) { lastResultHash = hash; controller.add(tasks); } } catch (e) { debugPrint('[tasksProvider] offline seed error: $e'); } return; } try { // Seed fetch: order newest-first and limit to 200 rows to reduce // initial payload. The realtime stream delivers subsequent changes. var seedQ = client.from('tasks').select(); if (query.status != null) seedQ = seedQ.eq('status', query.status!); final data = await seedQ .order('created_at', ascending: false) .range(0, kTaskPageLimit - 1); final raw = data .cast>() .map(Task.fromMap) .toList(); if (raw.length >= kTaskPageLimit) { debugPrint( '[tasksProvider] seed hit $kTaskPageLimit-row limit; ' 'older tasks arrive via realtime stream.', ); } final payload = _buildTaskPayload( tasks: raw, isGlobal: isGlobal, allowedTicketIds: allowedTicketIds, allowedOfficeIds: allowedOfficeIds, query: query, ); final processed = await compute(_processTasksInIsolate, payload); final tasks = (processed as List) .cast>() .map(Task.fromMap) .toList(); final hash = tasks.fold('', (h, t) => '$h${t.hashCode}'); if (!controller.isClosed && hash != lastResultHash) { lastResultHash = hash; controller.add(tasks); // emit immediately – no debounce } } catch (e) { debugPrint('[tasksProvider] initial seed error: $e'); } }), ); // ── Realtime stream ─────────────────────────────────────────────────────── // Processes every realtime event through the same isolate. Debounced so // rapid consecutive events (e.g. bulk inserts) don't cause repeated renders. final wrapperSub = wrapper.stream .asyncMap((result) async { final payload = _buildTaskPayload( tasks: result.data, isGlobal: isGlobal, allowedTicketIds: allowedTicketIds, allowedOfficeIds: allowedOfficeIds, query: query, ); final processed = await compute(_processTasksInIsolate, payload); return (processed as List) .cast>() .map(Task.fromMap) .toList(); }) .listen( (tasks) { final hash = tasks.fold('', (h, t) => '$h${t.hashCode}'); if (hash != lastResultHash) { lastResultHash = hash; emitDebounced(tasks); } }, onError: (Object e) { debugPrint('[tasksProvider] stream error: $e'); // Don't forward errors — the wrapper handles recovery internally. }, ); ref.onDispose(wrapperSub.cancel); return controller.stream; }); // Runs inside a background isolate to filter/sort tasks represented as // plain maps. Returns a List> suitable for // reconstruction with `Task.fromMap` on the main isolate. List> _processTasksInIsolate( Map payload, ) { var list = List>.from( (payload['rows'] as List).cast>(), ); final isGlobal = payload['isGlobal'] as bool? ?? false; final allowedTicketIds = (payload['allowedTicketIds'] as List?)?.cast().toSet() ?? {}; final allowedOfficeIds = (payload['allowedOfficeIds'] as List?)?.cast().toSet() ?? {}; if (!isGlobal) { if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) { return >[]; } list = list.where((t) { final tid = t['ticket_id'] as String?; final oid = t['office_id'] as String?; return (tid != null && allowedTicketIds.contains(tid)) || (oid != null && allowedOfficeIds.contains(oid)); }).toList(); } final officeId = payload['officeId'] as String?; if (officeId != null) { list = list.where((t) => t['office_id'] == officeId).toList(); } final status = payload['status'] as String?; if (status != null) { list = list.where((t) => t['status'] == status).toList(); } final searchQuery = (payload['searchQuery'] as String?) ?? ''; if (searchQuery.isNotEmpty) { final q = searchQuery.toLowerCase(); list = list.where((t) { final title = (t['title'] as String?)?.toLowerCase() ?? ''; final desc = (t['description'] as String?)?.toLowerCase() ?? ''; final tn = (t['task_number'] as String?)?.toLowerCase() ?? ''; return title.contains(q) || desc.contains(q) || tn.contains(q); }).toList(); } final taskNumberFilter = (payload['taskNumber'] as String?)?.trim(); if (taskNumberFilter != null && taskNumberFilter.isNotEmpty) { final tnLow = taskNumberFilter.toLowerCase(); list = list .where( (t) => ((t['task_number'] as String?) ?? '').toLowerCase().contains( tnLow, ), ) .toList(); } int statusRank(String s) { switch (s) { case 'queued': return 0; case 'in_progress': return 1; case 'completed': return 2; case 'cancelled': return 3; default: return 4; } } int parseCreatedAt(Map m) { final v = m['created_at']; if (v == null) return 0; if (v is int) return v; if (v is double) return v.toInt(); if (v is String) { try { return DateTime.parse(v).millisecondsSinceEpoch; } catch (_) { return 0; } } return 0; } list.sort((a, b) { final ra = statusRank((a['status'] as String?) ?? ''); final rb = statusRank((b['status'] as String?) ?? ''); final rcmp = ra.compareTo(rb); if (rcmp != 0) return rcmp; if (ra == 0) { // queued: higher priority first (lower number = higher priority), // then created_at descending (newest first) final pa = (a['priority'] as num?)?.toInt() ?? 1; final pb = (b['priority'] as num?)?.toInt() ?? 1; final pcmp = pb.compareTo(pa); if (pcmp != 0) return pcmp; // Same priority: sort by created_at descending (newest first) return parseCreatedAt(b).compareTo(parseCreatedAt(a)); } if (ra == 1) { // in_progress: higher priority first (lower number = higher priority), // then created_at descending (newest first) final pa = (a['priority'] as num?)?.toInt() ?? 1; final pb = (b['priority'] as num?)?.toInt() ?? 1; final pcmp = pb.compareTo(pa); if (pcmp != 0) return pcmp; // Same priority: sort by created_at descending (newest first) return parseCreatedAt(b).compareTo(parseCreatedAt(a)); } // completed and cancelled: sort by created_at descending (newest first) return parseCreatedAt(b).compareTo(parseCreatedAt(a)); }); return list; } /// Provider for task query parameters. final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); /// Derived provider that selects a single [Task] by ID from the tasks list. /// /// Checks the live Supabase stream first, then falls back to the /// [offlinePendingTasksProvider] so that tasks created while offline are /// visible in the detail screen before they sync. /// Pending offline field updates keyed by task ID. /// Merged into [taskByIdProvider] and replayed on reconnect. final offlinePendingTaskUpdatesProvider = StateProvider>>((ref) => const {}); final taskByIdProvider = Provider.family((ref, taskId) { Task? task = ref .watch(tasksProvider) .valueOrNull ?.where((t) => t.id == taskId) .firstOrNull; task ??= ref .watch(offlinePendingTasksProvider) .where((t) => t.id == taskId) .firstOrNull; if (task == null) return null; // Merge any pending offline field edits so the UI stays current. final updates = ref.watch(offlinePendingTaskUpdatesProvider)[taskId]; if (updates == null || updates.isEmpty) return task; return Task( id: task.id, ticketId: task.ticketId, taskNumber: task.taskNumber, title: updates['title'] as String? ?? task.title, description: updates['description'] as String? ?? task.description, officeId: updates['office_id'] as String? ?? task.officeId, status: updates['status'] as String? ?? task.status, priority: task.priority, queueOrder: task.queueOrder, createdAt: task.createdAt, creatorId: task.creatorId, startedAt: updates.containsKey('started_at') ? (updates['started_at'] == null ? null : DateTime.tryParse(updates['started_at'] as String)) : task.startedAt, completedAt: updates.containsKey('completed_at') ? (updates['completed_at'] == null ? null : DateTime.tryParse(updates['completed_at'] as String)) : task.completedAt, requestedBy: updates['requested_by'] as String? ?? task.requestedBy, notedBy: updates['noted_by'] as String? ?? task.notedBy, receivedBy: updates['received_by'] as String? ?? task.receivedBy, requestType: updates['request_type'] as String? ?? task.requestType, requestTypeOther: updates['request_type_other'] as String? ?? task.requestTypeOther, requestCategory: updates['request_category'] as String? ?? task.requestCategory, actionTaken: updates['action_taken'] as String? ?? task.actionTaken, cancellationReason: updates['cancellation_reason'] as String? ?? task.cancellationReason, cancelledAt: updates.containsKey('cancelled_at') ? (updates['cancelled_at'] == null ? null : DateTime.tryParse(updates['cancelled_at'] as String)) : task.cancelledAt, itJobPrinted: task.itJobPrinted, itJobPrintedAt: task.itJobPrintedAt, itJobReceivedById: task.itJobReceivedById, ); }); /// Returns true when [taskId] exists in the offline pending list (not yet synced). /// /// Use this in the detail screen to show a "pending sync" banner and to know /// whether editing operations are hitting a not-yet-created DB row. final isTaskPendingProvider = Provider.family((ref, taskId) { return ref.watch(offlinePendingTasksProvider).any((t) => t.id == taskId); }); /// Derived provider that selects a [Task] linked to a given ticket ID. /// /// Returns the first task whose `ticketId` matches [ticketId], or null. final taskByTicketIdProvider = Provider.family((ref, ticketId) { return ref .watch(tasksProvider) .valueOrNull ?.where((t) => t.ticketId == ticketId) .firstOrNull; }); final taskAssignmentsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('task_assignments') .stream(primaryKey: ['task_id', 'user_id']), onPollData: () async { final data = await client.from('task_assignments').select(); return data.map(TaskAssignment.fromMap).toList(); }, fromMap: TaskAssignment.fromMap, channelName: 'task_assignments', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); /// Stream of activity logs for a single task. final taskActivityLogsProvider = StreamProvider.family, String>((ref, taskId) { final client = ref.watch(supabaseClientProvider); // Watch offline-pending typed items. When _queueActivityLog adds a new // entry this provider rebuilds, creating a fresh StreamRecoveryWrapper // whose _hasEmittedData=false — so _emitOfflineData() fires immediately // and the closed-over pendingItems list already contains the new log. final pendingItems = ref .watch(offlinePendingActivityLogItemsProvider) .where((l) => l.taskId == taskId) .toList(); final wrapper = StreamRecoveryWrapper( stream: client .from('task_activity_logs') .stream(primaryKey: ['id']) .eq('task_id', taskId) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('task_activity_logs') .select() .eq('task_id', taskId) .order('created_at', ascending: false); return data.map((r) => TaskActivityLog.fromMap(r)).toList(); }, fromMap: TaskActivityLog.fromMap, channelName: 'task_activity_logs:$taskId', onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, onOfflineData: () async { // Brick SQLite (cold-launch cache) + any in-memory pending logs // that haven't been written to Brick yet (async timing gap). final all = await cachedListFromBrick(); final filtered = all.where((l) => l.taskId == taskId).toList(); // Dedupe: pendingItems may already be in Brick after a hot-restart final brickIds = filtered.map((l) => l.id).toSet(); final extras = pendingItems.where((l) => !brickIds.contains(l.id)).toList(); return [...extras, ...filtered] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); }, onCacheMirror: (rows) => mirrorBatchToBrick( rows, tag: 'task_activity_logs', ), ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) { // For live/polling emissions, also merge any pending offline logs not // yet echoed back from Supabase realtime (avoids a brief flash where // the log disappears between queue and server confirmation). final liveIds = result.data.map((l) => l.id).toSet(); final extras = pendingItems.where((l) => !liveIds.contains(l.id)).toList(); final merged = [...extras, ...result.data] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return _dedupeActivityLogs(merged); }); }); final taskAssignmentsControllerProvider = Provider(( ref, ) { final client = ref.watch(supabaseClientProvider); return TaskAssignmentsController(client, ref); }); /// Tasks created while offline that are pending sync to Supabase. /// Merged with the live [tasksProvider] stream in the UI so the new task /// appears immediately without invalidating (and destroying) the stream. /// Entries are automatically removed when the realtime stream returns a task /// with the same UUID (meaning the offline write synced successfully). final offlinePendingTasksProvider = StateProvider>((ref) => const []); /// Offline-queued task messages: task ID → list of raw INSERT payloads /// {task_id, ticket_id?, content, sender_id, created_at}. /// Shown optimistically in the UI and replayed on reconnect via the /// [tasksProvider] listener (which bypasses Brick's potentially-stale queue). final offlinePendingMessagesProvider = StateProvider>>>((ref) => const {}); /// Offline-pending IT staff assignment replacements: task ID → full new user ID list. /// Replayed as a DELETE + INSERT pair on reconnect to avoid partial diffs. final offlinePendingAssignmentsProvider = StateProvider>>((ref) => const {}); /// Offline-queued activity log rows. Each entry is a complete INSERT payload /// including a [created_at] timestamp stamped at the time of the offline action /// (not at replay time) so the audit trail reflects when the action occurred. final offlinePendingActivityLogsProvider = StateProvider>>((ref) => const []); /// Typed offline-pending activity log objects. Watched by /// [taskActivityLogsProvider] so the UI updates immediately when an activity /// is logged offline — without waiting for Brick SQLite async writes. final offlinePendingActivityLogItemsProvider = StateProvider>((ref) => const []); final tasksControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return TasksController(client, ref); }); class TasksController { TasksController(this._client, [this._ref]); // Supabase storage bucket for task action images. Ensure this bucket exists // with public read access. static const String _actionImageBucket = 'task_action_taken_images'; // _client is declared dynamic allowing test doubles that mimic only the // subset of methods used by this class. In production it will be a // SupabaseClient instance. final dynamic _client; // Optional — only provided when constructed via [tasksControllerProvider]. // Used to update [offlinePendingTasksProvider] after an offline upsert. final Ref? _ref; Future _isTaskCurrentlyPaused(String taskId) async { try { final rows = await _client .from('task_activity_logs') .select('action_type, created_at') .eq('task_id', taskId) .inFilter('action_type', [ 'started', 'paused', 'resumed', 'completed', 'cancelled', ]) .order('created_at', ascending: false) .limit(1); if (rows is List && rows.isNotEmpty) { final latest = rows.first; final action = latest['action_type']?.toString() ?? ''; return action == 'paused'; } } catch (_) {} return false; } Future pauseTask({required String taskId}) async { final isOnline = _ref?.read(isOnlineProvider) ?? true; if (!isOnline) { _queueActivityLog({ 'task_id': taskId, 'actor_id': _client.auth.currentUser?.id, 'action_type': 'paused', }); return; } final row = await _client .from('tasks') .select('status') .eq('id', taskId) .maybeSingle(); if (row is! Map) { throw Exception('Task not found'); } final status = (row['status'] as String?)?.trim() ?? ''; if (status == 'completed' || status == 'cancelled' || status == 'closed') { throw Exception('Cannot pause a terminal task.'); } if (status != 'in_progress') { throw Exception('Only in-progress tasks can be paused.'); } final alreadyPaused = await _isTaskCurrentlyPaused(taskId); if (alreadyPaused) { return; } final actorId = _client.auth.currentUser?.id; await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'paused', }); } Future resumeTask({required String taskId}) async { final isOnline = _ref?.read(isOnlineProvider) ?? true; if (!isOnline) { _queueActivityLog({ 'task_id': taskId, 'actor_id': _client.auth.currentUser?.id, 'action_type': 'resumed', }); return; } final row = await _client .from('tasks') .select('status') .eq('id', taskId) .maybeSingle(); if (row is! Map) { throw Exception('Task not found'); } final status = (row['status'] as String?)?.trim() ?? ''; if (status == 'completed' || status == 'cancelled' || status == 'closed') { throw Exception('Cannot resume a terminal task.'); } if (status != 'in_progress') { throw Exception('Only in-progress tasks can be resumed.'); } final isPaused = await _isTaskCurrentlyPaused(taskId); if (!isPaused) { return; } final actorId = _client.auth.currentUser?.id; await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'resumed', }); } Future createTask({ required String title, required String description, String? officeId, String? ticketId, // optional request metadata when creating a task String? requestType, String? requestTypeOther, String? requestCategory, }) async { final actorId = _client.auth.currentUser?.id; final payload = { 'title': title, 'description': description, }; if (officeId != null) { payload['office_id'] = officeId; } if (ticketId != null) { payload['ticket_id'] = ticketId; } if (requestType != null) { payload['request_type'] = requestType; } if (requestTypeOther != null) { payload['request_type_other'] = requestTypeOther; } if (requestCategory != null) { payload['request_category'] = requestCategory; } // Prefer server RPC that atomically generates `task_number` and inserts // the task; fallback to client-side insert with retry on duplicate-key. // Generate UUID before the network call so Brick's offline queue captures // the same UUID in the queued request — preventing a UUID mismatch on replay. final offlineId = const Uuid().v4(); String? taskId; String? assignedNumber; // Optimistic update: add the pending task BEFORE any network call so it // appears in the UI immediately. On online success the live Supabase stream // delivers the task with the same offlineId and ref.listen in // tasks_list_screen removes it from the pending list automatically. // On offline the task stays pending until the queued INSERT syncs. final offlineTask = Task( id: offlineId, title: title, description: description, officeId: officeId, ticketId: ticketId, taskNumber: null, status: 'queued', priority: 1, queueOrder: null, createdAt: DateTime.now(), creatorId: actorId, startedAt: null, completedAt: null, requestType: requestType, requestTypeOther: requestTypeOther, requestCategory: requestCategory, ); final ref = _ref; if (ref != null && AppRepository.isConfigured) { ref.read(offlinePendingTasksProvider.notifier).state = [ offlineTask, ...ref.read(offlinePendingTasksProvider), ]; } // When offline, skip the RPC entirely to avoid queuing a request with // wrong param names (title vs p_title). A queued RPC that gets 504s on // replay blocks Brick's queue indefinitely, preventing the INSERT below // from ever syncing. Going straight to the INSERT-only path means only // one clean entry sits in the queue. final isOnline = ref?.read(isOnlineProvider) ?? true; if (!isOnline && AppRepository.isConfigured) { try { await AppRepository.instance.sqliteProvider .upsert(offlineTask, repository: AppRepository.instance); debugPrint('createTask offline — saved to local SQLite id=$offlineId'); } catch (localErr) { debugPrint('createTask offline local SQLite write failed: $localErr'); } payload['id'] = offlineId; try { await _client .from('tasks') .insert(payload) .select('id, task_number') .single(); } catch (insertErr) { if (isOfflineSaveError(insertErr)) { debugPrint( 'createTask offline — INSERT queued for sync id=$offlineId', ); } else { debugPrint('createTask offline INSERT queue error: $insertErr'); } } // Queue the 'created' activity log — the online path inserts it after the // RPC/INSERT returns a taskId, which never happens here in the offline path. _queueActivityLog({ 'task_id': offlineId, 'actor_id': actorId, 'action_type': 'created', }); throw const PostgrestException( message: 'unknown internal error', code: '501', details: 'queued offline', ); } try { final rpcParams = Map.from(payload); rpcParams['id'] = offlineId; // Retry RPC on duplicate-key (23505) errors which may occur // transiently due to concurrent inserts; prefer RPC always. const int rpcMaxAttempts = 3; Map? rpcRow; for (var attempt = 0; attempt < rpcMaxAttempts; attempt++) { try { final rpcRes = await _client .rpc('insert_task_with_number', rpcParams) .single(); if (rpcRes is Map) { rpcRow = Map.from(rpcRes); } else if (rpcRes is List && rpcRes.isNotEmpty && rpcRes.first is Map) { rpcRow = Map.from(rpcRes.first as Map); } break; } catch (err) { final msg = err.toString(); final isDuplicateKey = msg.contains('duplicate key value') || msg.contains('23505'); if (!isDuplicateKey || attempt == rpcMaxAttempts - 1) { rethrow; } await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); // retry } } if (rpcRow != null) { taskId = rpcRow['id'] as String?; assignedNumber = rpcRow['task_number'] as String?; } debugPrint( 'createTask via RPC assigned number=$assignedNumber id=$taskId', ); } catch (e) { debugPrint('createTask error: ${e.runtimeType}: $e'); if (isOfflineSaveError(e) && AppRepository.isConfigured) { // Pending list already updated above. Write to local SQLite and queue // a direct INSERT (the path that works online via trigger). try { await AppRepository.instance.sqliteProvider .upsert(offlineTask, repository: AppRepository.instance); debugPrint('createTask offline — saved to local SQLite id=$offlineId'); } catch (localErr) { debugPrint('createTask offline local SQLite write failed: $localErr'); } payload['id'] = offlineId; try { await _client .from('tasks') .insert(payload) .select('id, task_number') .single(); } catch (insertErr) { if (isOfflineSaveError(insertErr)) { debugPrint( 'createTask offline — direct INSERT queued for sync id=$offlineId', ); } else { debugPrint('createTask offline INSERT queue error: $insertErr'); } } rethrow; } // Include the pre-generated UUID in the fallback insert so the server uses // the same ID whether we go through the RPC or the direct insert path. payload['id'] = offlineId; // RPC not available or failed online; fallback to client insert with retry const int maxAttempts = 3; Map? insertData; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { insertData = await _client .from('tasks') .insert(payload) .select('id, task_number') .single(); break; } catch (err) { final msg = err.toString(); final isDuplicateKey = msg.contains('duplicate key value') || msg.contains('23505'); if (isOfflineSaveError(err)) { // Offline caught at fallback — pending already updated above, // Brick has queued the INSERT for replay on reconnect. debugPrint( 'createTask offline at fallback INSERT id=$offlineId', ); rethrow; } if (!isDuplicateKey || attempt == maxAttempts - 1) { // Server error — remove the optimistic pending entry (it won't sync). if (ref != null && AppRepository.isConfigured) { ref.read(offlinePendingTasksProvider.notifier).state = ref .read(offlinePendingTasksProvider) .where((t) => t.id != offlineId) .toList(); } rethrow; } await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); } } taskId = insertData == null ? null : insertData['id'] as String?; assignedNumber = insertData == null ? null : insertData['task_number'] as String?; debugPrint( 'createTask fallback assigned number=$assignedNumber id=$taskId', ); } if (taskId == null) return; try { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'created', }); } catch (_) { // non-fatal } // Auto-assignment should run once on creation (best-effort). try { await _autoAssignTask(taskId: taskId, officeId: officeId ?? ''); } catch (e, st) { // keep creation successful but surface the error in logs for debugging debugPrint('autoAssignTask failed for task=$taskId: $e\n$st'); } unawaited(_notifyCreated(taskId: taskId, actorId: actorId)); } /// Uploads an image for a task's action field and returns the public URL. /// /// [bytes] should contain the file data and [extension] the file extension /// (e.g. 'png' or 'jpg'). The image will be stored under a path that /// includes the task ID and a timestamp to avoid collisions. Returns `null` /// if the upload fails. Future uploadActionImage({ required String taskId, required Uint8List bytes, required String extension, }) async { final path = 'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension'; try { // debug: show upload path debugPrint('uploadActionImage uploading to path: $path'); // perform the upload and capture whatever the SDK returns (it varies by platform) final dynamic res; if (kIsWeb) { // on web, upload binary data res = await _client.storage .from(_actionImageBucket) .uploadBinary(path, bytes); } else { // write bytes to a simple temp file (no nested folders) final tmpDir = Directory.systemTemp; final localFile = File( '${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}.$extension', ); try { await localFile.create(); await localFile.writeAsBytes(bytes); } catch (e) { debugPrint('uploadActionImage failed writing temp file: $e'); return null; } res = await _client.storage .from(_actionImageBucket) .upload(path, localFile); try { await localFile.delete(); } catch (_) {} } debugPrint( 'uploadActionImage response type=${res.runtimeType} value=$res', ); // Some SDK methods return a simple String (path) on success, others // return a StorageResponse with an error field. Avoid calling .error on a // String to prevent NoSuchMethodError as seen in logs earlier. if (res is String) { // treat as success } else if (res is Map && res['error'] != null) { // older versions might return a plain map debugPrint('uploadActionImage upload error: ${res['error']}'); return null; } else if (res != null && res.error != null) { // StorageResponse case debugPrint('uploadActionImage upload error: ${res.error}'); return null; } } catch (e) { debugPrint('uploadActionImage failed upload: $e'); return null; } try { final urlRes = await _client.storage .from(_actionImageBucket) .getPublicUrl(path); // debug: log full response debugPrint('uploadActionImage getPublicUrl response: $urlRes'); String? url; if (urlRes is String) { url = urlRes; } else if (urlRes is Map && urlRes['data'] is String) { url = urlRes['data'] as String; } else if (urlRes != null) { try { url = urlRes.data as String?; } catch (_) { url = null; } } if (url != null && url.isNotEmpty) { // trim whitespace/newline which may be added by SDK or logging return url.trim(); } // fallback: construct URL manually using env variable final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; if (supabaseUrl.isEmpty) return null; return '$supabaseUrl/storage/v1/object/public/$_actionImageBucket/$path' .trim(); } catch (e) { debugPrint('uploadActionImage getPublicUrl error: $e'); return null; } } Future uploadTaskAttachment({ required String taskId, required String fileName, required Uint8List bytes, }) async { final path = '$taskId/$fileName'; try { debugPrint('uploadTaskAttachment uploading to path: $path'); final dynamic res; if (kIsWeb) { // on web, upload binary data res = await _client.storage .from('task_attachments') .uploadBinary(path, bytes); } else { // write bytes to a simple temp file (no nested folders) final tmpDir = Directory.systemTemp; final localFile = File( '${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}_$fileName', ); try { await localFile.create(); await localFile.writeAsBytes(bytes); } catch (e) { debugPrint('uploadTaskAttachment failed writing temp file: $e'); rethrow; } res = await _client.storage .from('task_attachments') .upload(path, localFile); try { await localFile.delete(); } catch (_) {} } debugPrint( 'uploadTaskAttachment response type=${res.runtimeType} value=$res', ); // Check for errors if (res is String) { // treat as success } else if (res is Map && res['error'] != null) { debugPrint('uploadTaskAttachment upload error: ${res['error']}'); throw Exception('Upload error: ${res['error']}'); } else if (res != null && res.error != null) { debugPrint('uploadTaskAttachment upload error: ${res.error}'); throw Exception('Upload error: ${res.error}'); } } catch (e) { debugPrint('uploadTaskAttachment failed: $e'); rethrow; } } Future _notifyCreated({ required String taskId, required String? actorId, }) async { try { final recipients = await _fetchRoleUserIds( roles: const ['dispatcher', 'it_staff'], excludeUserId: actorId, ); if (recipients.isEmpty) return; final rows = recipients .map( (userId) => { 'user_id': userId, 'actor_id': actorId, 'task_id': taskId, 'type': 'created', }, ) .toList(); await _client.from('notifications').insert(rows); // Send FCM pushes with meaningful text try { // resolve actor display name if possible String actorName = 'Someone'; if (actorId != null && actorId.isNotEmpty) { try { final p = await _client .from('profiles') .select('full_name,display_name,name') .eq('id', actorId) .maybeSingle(); if (p != null) { if (p['full_name'] != null) { actorName = p['full_name'].toString(); } else if (p['display_name'] != null) { actorName = p['display_name'].toString(); } else if (p['name'] != null) { actorName = p['name'].toString(); } } } catch (_) {} } // fetch task_number and office (try embedding office.name); fallback to offices lookup String? taskNumber; String? officeId; String? officeName; try { final t = await _client .from('tasks') .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); } if (t['office_id'] != null) officeId = t['office_id'].toString(); final dynOffices = t['offices']; if (dynOffices != null) { if (dynOffices is List && dynOffices.isNotEmpty && dynOffices.first['name'] != null) { officeName = dynOffices.first['name'].toString(); } else if (dynOffices is Map && dynOffices['name'] != null) { officeName = dynOffices['name'].toString(); } } } } catch (_) {} if ((officeName == null || officeName.isEmpty) && officeId != null && officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); if (o != null && o['name'] != null) { officeName = o['name'].toString(); } } catch (_) {} } final title = 'New task'; final body = taskNumber != null ? (officeName != null ? '$actorName created task #$taskNumber in $officeName.' : '$actorName created task #$taskNumber.') : (officeName != null ? '$actorName created a new task in $officeName.' : '$actorName created a new task.'); final dataPayload = { 'type': 'created', 'task_id': taskId, 'task_number': ?taskNumber, 'office_id': ?officeId, 'office_name': ?officeName, }; await _client.functions.invoke( 'send_fcm', body: { 'user_ids': recipients, 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (e) { // non-fatal: push failure should not break flow debugPrint('notifyCreated push error: $e'); } } catch (_) { return; } } Future> _fetchRoleUserIds({ required List roles, required String? excludeUserId, }) async { try { final data = await _client .from('profiles') .select('id, role') .inFilter('role', roles); final rows = data; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } /// Update only the status of a task. /// /// Before marking a task as **completed** we enforce that the /// request type/category metadata have been provided. This protects the /// business rule that details must be specified before closing. Future updateTaskStatus({ required String taskId, required String status, String? reason, }) async { // Short-circuit when offline: skip server-side validation and queue the update. final isOnline = _ref?.read(isOnlineProvider) ?? true; if (!isOnline) { if (status == 'cancelled' && (reason == null || reason.trim().isEmpty)) { throw Exception('Cancellation requires a reason.'); } final payload = {'status': status}; if (status == 'cancelled') { payload['cancellation_reason'] = reason; payload['cancelled_at'] = AppTime.now().toIso8601String(); } if (status == 'in_progress') { payload['started_at'] = AppTime.now().toIso8601String(); } if (status == 'completed') { payload['completed_at'] = AppTime.now().toIso8601String(); } _trackOfflineUpdate(taskId, payload); // Queue the corresponding activity log so it replays on reconnect. final actionType = status == 'in_progress' ? 'started' : status == 'completed' ? 'completed' : status == 'cancelled' ? 'cancelled' : null; if (actionType != null) { _queueActivityLog({ 'task_id': taskId, 'actor_id': _client.auth.currentUser?.id, 'action_type': actionType, if (actionType == 'cancelled' && reason != null) 'meta': {'reason': reason}, }); } throw const SocketException('Offline – status queued for sync'); } Map? taskRow; try { final row = await _client .from('tasks') .select('status, request_type, request_category, action_taken') .eq('id', taskId) .maybeSingle(); if (row is! Map) { throw Exception('Task not found'); } taskRow = row; } catch (e) { rethrow; } final currentStatus = (taskRow['status'] as String?)?.trim() ?? ''; if (currentStatus == 'closed' || currentStatus == 'cancelled' || currentStatus == 'completed') { if (currentStatus != status) { throw Exception( 'Status cannot be changed after a task is $currentStatus.', ); } return; } if (status == 'cancelled') { if (reason == null || reason.trim().isEmpty) { throw Exception('Cancellation requires a reason.'); } } if (status == 'in_progress') { final assignmentRows = await _client .from('task_assignments') .select('user_id') .eq('task_id', taskId); final assignedUserIds = (assignmentRows as List) .map((row) => row['user_id']?.toString()) .whereType() .where((id) => id.isNotEmpty) .toSet() .toList(); if (assignedUserIds.isEmpty) { throw Exception( 'Assign at least one IT Staff before starting this task.', ); } final itStaffRows = await _client .from('profiles') .select('id') .inFilter('id', assignedUserIds) .eq('role', 'it_staff'); final hasItStaff = (itStaffRows as List).isNotEmpty; if (!hasItStaff) { throw Exception( 'Assign at least one IT Staff before starting this task.', ); } } if (status == 'completed') { // Check for IT staff assignment final assignmentRows = await _client .from('task_assignments') .select('user_id') .eq('task_id', taskId); final assignedUserIds = (assignmentRows as List) .map((row) => row['user_id']?.toString()) .whereType() .where((id) => id.isNotEmpty) .toSet() .toList(); if (assignedUserIds.isEmpty) { throw Exception( 'Assign at least one IT Staff before completing this task.', ); } final itStaffRows = await _client .from('profiles') .select('id') .inFilter('id', assignedUserIds) .eq('role', 'it_staff'); final hasItStaff = (itStaffRows as List).isNotEmpty; if (!hasItStaff) { throw Exception( 'Assign at least one IT Staff before completing this task.', ); } // Check required fields try { final rt = taskRow['request_type']; final rc = taskRow['request_category']; final action = taskRow['action_taken']; final missing = []; if (rt == null || (rt is String && rt.trim().isEmpty)) { missing.add('request type'); } if (rc == null || (rc is String && rc.trim().isEmpty)) { missing.add('request category'); } // signatories are no longer required for completion; they can be // filled in later. we still require action taken to document what // was done. // if you want to enforce action taken you can uncomment this block, // but current business rule only mandates request metadata. we keep // action taken non-null for clarity. if (action == null || (action is String && action.trim().isEmpty)) { missing.add('action taken'); } if (missing.isNotEmpty) { throw Exception( 'The following fields must be set before completing a task: ${missing.join(', ')}.', ); } } catch (e) { // rethrow so callers can handle (UI will display message) rethrow; } } // persist status and cancellation reason (when provided) final payload = {'status': status}; if (status == 'cancelled') { payload['cancellation_reason'] = reason; } await _client.from('tasks').update(payload).eq('id', taskId); // if cancelled, also set cancelled_at timestamp if (status == 'cancelled') { try { final cancelledAt = AppTime.now().toIso8601String(); await _client .from('tasks') .update({'cancelled_at': cancelledAt}) .eq('id', taskId); } catch (_) {} } // Log important status transitions try { final actorId = _client.auth.currentUser?.id; if (status == 'in_progress') { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'started', }); } else if (status == 'completed') { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'completed', }); } else if (status == 'cancelled') { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'cancelled', 'meta': {'reason': reason}, }); } } catch (_) { // ignore logging failures } } /// Update arbitrary fields on a task row. /// /// Primarily used to set request metadata after creation or during /// status transitions. Future updateTask({ required String taskId, String? requestType, String? requestTypeOther, String? requestCategory, String? status, String? requestedBy, String? notedBy, String? receivedBy, String? actionTaken, }) async { final payload = {}; if (requestType != null) { payload['request_type'] = requestType; } if (requestTypeOther != null) { payload['request_type_other'] = requestTypeOther; } if (requestCategory != null) { payload['request_category'] = requestCategory; } if (requestedBy != null) { payload['requested_by'] = requestedBy; } if (notedBy != null) { payload['noted_by'] = notedBy; } // `performed_by` is derived from task assignments; we don't persist it here. if (receivedBy != null) { payload['received_by'] = receivedBy; } if (actionTaken != null) { try { payload['action_taken'] = jsonDecode(actionTaken); } catch (_) { // fallback: store raw string payload['action_taken'] = actionTaken; } } if (status != null) { payload['status'] = status; } if (payload.isEmpty) { return; } try { await _client.from('tasks').update(payload).eq('id', taskId); // Clear any pending offline updates for this task now that the server has them. _clearPendingUpdate(taskId); } catch (e) { if (isOfflineSaveError(e)) { // Store the raw action_taken string (not the decoded JSON) so that // taskByIdProvider can reconstruct the Task.actionTaken field correctly. final pendingPayload = Map.from(payload); if (actionTaken != null) pendingPayload['action_taken'] = actionTaken; _trackOfflineUpdate(taskId, pendingPayload); _queueActivityLog({ 'task_id': taskId, 'actor_id': _client.auth.currentUser?.id, 'action_type': 'updated', }); } rethrow; } // Record activity logs for any metadata/signatory fields that were changed try { final actorId = _client.auth.currentUser?.id; final List> logRows = []; if (requestType != null) { logRows.add({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'filled_request_type', 'meta': {'value': requestType}, }); } if (requestCategory != null) { logRows.add({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'filled_request_category', 'meta': {'value': requestCategory}, }); } if (requestedBy != null) { logRows.add({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'filled_requested_by', 'meta': {'value': requestedBy}, }); } if (notedBy != null) { logRows.add({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'filled_noted_by', 'meta': {'value': notedBy}, }); } if (receivedBy != null) { logRows.add({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'filled_received_by', 'meta': {'value': receivedBy}, }); } if (actionTaken != null) { logRows.add({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'filled_action_taken', 'meta': {'value': actionTaken}, }); } if (logRows.isNotEmpty) { await _insertActivityRows(_client, logRows); } } catch (_) {} } /// Update editable task fields such as title, description, office or linked ticket. Future updateTaskFields({ required String taskId, String? title, String? description, String? officeId, String? ticketId, }) async { final payload = {}; if (title != null) payload['title'] = title; if (description != null) payload['description'] = description; if (officeId != null) payload['office_id'] = officeId; if (ticketId != null) payload['ticket_id'] = ticketId; if (payload.isEmpty) return; try { await _client.from('tasks').update(payload).eq('id', taskId); _clearPendingUpdate(taskId); } catch (e) { if (isOfflineSaveError(e)) { _trackOfflineUpdate(taskId, payload); _queueActivityLog({ 'task_id': taskId, 'actor_id': _client.auth.currentUser?.id, 'action_type': 'updated', }); } rethrow; } // record an activity log for edit operations (best-effort) try { final actorId = _client.auth.currentUser?.id; await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'updated', 'meta': payload, }); } catch (_) {} } /// Accumulate offline field changes so [taskByIdProvider] can merge them /// into the displayed task and [tasksProvider]'s reconnect listener can /// replay them directly to Supabase REST (bypassing Brick's stale queue). void _trackOfflineUpdate(String taskId, Map payload) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingTaskUpdatesProvider), ); final existing = Map.from(current[taskId] ?? {}); existing.addAll(payload); current[taskId] = existing; ref.read(offlinePendingTaskUpdatesProvider.notifier).state = current; } /// Remove pending updates for [taskId] after a successful server write. void _clearPendingUpdate(String taskId) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingTaskUpdatesProvider), ); if (current.remove(taskId) != null) { ref.read(offlinePendingTaskUpdatesProvider.notifier).state = current; } } /// Queue an activity log row for offline-then-reconnect replay. /// Stamps [created_at] NOW so the audit trail reflects the offline action time. /// Also writes to Brick SQLite so the log is visible in the offline UI immediately /// (taskActivityLogsProvider.onOfflineData reads from Brick, not the RAM queue). void _queueActivityLog(Map row) { final ref = _ref; if (ref == null) return; final fullRow = { 'id': const Uuid().v4(), // pre-generate so server INSERT uses same UUID ...row, 'created_at': AppTime.now().toIso8601String(), }; // RAM queue for reconnect-replay step 5 final current = List>.from( ref.read(offlinePendingActivityLogsProvider), ); current.add(fullRow); ref.read(offlinePendingActivityLogsProvider.notifier).state = List.unmodifiable(current); // Typed state — watched by taskActivityLogsProvider so the UI rebuilds // immediately (synchronous write, no Brick async timing race). final logModel = TaskActivityLog.fromMap(fullRow); final currentItems = List.from( ref.read(offlinePendingActivityLogItemsProvider), ); currentItems.add(logModel); ref.read(offlinePendingActivityLogItemsProvider.notifier).state = List.unmodifiable(currentItems); // Brick SQLite write — ensures the log survives app restart (cold-launch). mirrorBatchToBrick( [logModel], tag: 'offline_activity_log', ); } // Auto-assignment logic executed once on creation. Future _autoAssignTask({ required String taskId, required String officeId, }) async { if (officeId.isEmpty) return; final now = AppTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); final nextDay = startOfDay.add(const Duration(days: 1)); try { // 1) Find teams covering the office final teamsRows = (await _client.from('teams').select()) as List? ?? []; final teamIds = teamsRows .where((r) => (r['office_ids'] as List?)?.contains(officeId) == true) .map((r) => r['id'] as String) .toSet() .toList(); if (teamIds.isEmpty) return; // 2) Get members of those teams final memberRows = (await _client .from('team_members') .select('user_id') .inFilter('team_id', teamIds)) as List? ?? []; final candidateIds = memberRows .map((r) => r['user_id'] as String) .toSet() .toList(); if (candidateIds.isEmpty) return; // 3) Filter by "On Duty" (have a check-in record for today) final dsRows = (await _client .from('duty_schedules') .select('user_id, check_in_at') .inFilter('user_id', candidateIds)) as List? ?? []; final Map onDuty = {}; for (final r in dsRows) { final userId = r['user_id'] as String?; final checkIn = r['check_in_at'] as String?; if (userId == null || checkIn == null) continue; final dt = DateTime.tryParse(checkIn); if (dt == null) continue; if (dt.isAfter(startOfDay.subtract(const Duration(seconds: 1))) && dt.isBefore(nextDay.add(const Duration(seconds: 1)))) { onDuty[userId] = dt; } } if (onDuty.isEmpty) { // record a failed auto-assign attempt for observability try { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': null, 'action_type': 'auto_assign_failed', 'meta': {'reason': 'no_on_duty_candidates'}, }); } catch (_) {} return; } // 4) For each on-duty user compute completed_tasks_count for today final List<_Candidate> candidates = []; for (final userId in onDuty.keys) { // get task ids assigned to user final taRows = (await _client .from('task_assignments') .select('task_id') .eq('user_id', userId)) as List? ?? []; final assignedTaskIds = taRows .map((r) => r['task_id'] as String) .toList(); int completedCount = 0; if (assignedTaskIds.isNotEmpty) { final tasksRows = (await _client .from('tasks') .select('id') .inFilter('id', assignedTaskIds) .gte('completed_at', startOfDay.toIso8601String()) .lt('completed_at', nextDay.toIso8601String())) as List? ?? []; completedCount = tasksRows.length; } candidates.add( _Candidate( userId: userId, checkInAt: onDuty[userId]!, completedToday: completedCount, ), ); } if (candidates.isEmpty) { try { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': null, 'action_type': 'auto_assign_failed', 'meta': {'reason': 'no_eligible_candidates'}, }); } catch (_) {} return; } // 5) Sort: latest check-in first (desc), then lowest completed_today candidates.sort((a, b) { final c = b.checkInAt.compareTo(a.checkInAt); if (c != 0) return c; return a.completedToday.compareTo(b.completedToday); }); final chosen = candidates.first; // 6) Insert assignment + activity log + notification await _client.from('task_assignments').insert({ 'task_id': taskId, 'user_id': chosen.userId, }); try { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': null, 'action_type': 'assigned', 'meta': {'auto': true, 'user_id': chosen.userId}, }); } catch (_) {} try { await _client.from('notifications').insert({ 'user_id': chosen.userId, 'actor_id': null, 'task_id': taskId, 'type': 'assignment', }); // send push for auto-assignment try { final actorName = 'Dispatcher'; // fetch task_number and office for nicer deep-linking when available String? taskNumber; String? officeId; String? officeName; try { final t = await _client .from('tasks') .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); } if (t['office_id'] != null) officeId = t['office_id'].toString(); final dynOffices = t['offices']; if (dynOffices != null) { if (dynOffices is List && dynOffices.isNotEmpty && dynOffices.first['name'] != null) { officeName = dynOffices.first['name'].toString(); } else if (dynOffices is Map && dynOffices['name'] != null) { officeName = dynOffices['name'].toString(); } } } } catch (_) {} if ((officeName == null || officeName.isEmpty) && officeId != null && officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); if (o != null && o['name'] != null) { officeName = o['name'].toString(); } } catch (_) {} } final dataPayload = { 'type': 'assignment', 'task_id': taskId, 'task_number': ?taskNumber, 'office_id': ?officeId, 'office_name': ?officeName, }; final title = 'Task assigned'; final body = taskNumber != null ? (officeName != null ? '$actorName assigned you task #$taskNumber in $officeName.' : '$actorName assigned you task #$taskNumber.') : (officeName != null ? '$actorName assigned you a task in $officeName.' : '$actorName assigned you a task.'); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': [chosen.userId], 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (e) { debugPrint('autoAssign push error: $e'); } } catch (_) {} } catch (e, st) { // Log error for visibility and record a failed auto-assign activity debugPrint('autoAssignTask error for task=$taskId: $e\n$st'); try { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': null, 'action_type': 'auto_assign_failed', 'meta': {'reason': 'exception', 'error': e.toString()}, }); } catch (_) {} return; } } /// Mark a task's printed IT Job as received by [receivedById]. /// /// Uses a SECURITY DEFINER RPC to bypass RLS UPDATE restrictions /// that would otherwise silently block the dispatcher's update. Future markItJobPrinted(String taskId, {required String receivedById}) async { await _client.rpc('mark_it_job_printed', params: { 'p_task_id': taskId, 'p_receiver_id': receivedById, }); } /// Unmark a task's printed IT Job submission. Future markItJobNotPrinted(String taskId) async { await _client.rpc('unmark_it_job_printed', params: { 'p_task_id': taskId, }); } } /// Public DTO used by unit tests to validate selection logic. class AutoAssignCandidate { AutoAssignCandidate({ required this.userId, required this.checkInAt, required this.completedToday, }); final String userId; final DateTime checkInAt; final int completedToday; } /// Choose the best candidate according to auto-assignment rules: /// - latest check-in first (late-comer priority) /// - tie-breaker: lowest completedTasks (today) /// Returns the chosen userId or null when candidates is empty. String? chooseAutoAssignCandidate(List candidates) { if (candidates.isEmpty) return null; final list = List.from(candidates); list.sort((a, b) { final c = b.checkInAt.compareTo(a.checkInAt); if (c != 0) return c; return a.completedToday.compareTo(b.completedToday); }); return list.first.userId; } class _Candidate { _Candidate({ required this.userId, required this.checkInAt, required this.completedToday, }); final String userId; final DateTime checkInAt; final int completedToday; } class TaskAssignmentsController { TaskAssignmentsController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; void _trackOfflineAssignment(String taskId, List userIds) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingAssignmentsProvider), ); current[taskId] = userIds; ref.read(offlinePendingAssignmentsProvider.notifier).state = current; } void _clearOfflineAssignment(String taskId) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingAssignmentsProvider), ); if (current.remove(taskId) != null) { ref.read(offlinePendingAssignmentsProvider.notifier).state = current; } } Future replaceAssignments({ required String taskId, required String? ticketId, required List newUserIds, required List currentUserIds, }) async { final nextIds = newUserIds.toSet(); final currentIds = currentUserIds.toSet(); final toAdd = nextIds.difference(currentIds).toList(); final toRemove = currentIds.difference(nextIds).toList(); // Short-circuit: caller's currentUserIds already matches the desired state. // If a pending offline entry exists for this task, leave it alone — wiping // it would silently drop the assignment before the reconnect-replay runs. if (toAdd.isEmpty && toRemove.isEmpty) { return; } try { if (toAdd.isNotEmpty) { final rows = toAdd .map((userId) => {'task_id': taskId, 'user_id': userId}) .toList(); await _client.from('task_assignments').insert(rows); } if (toRemove.isNotEmpty) { await _client .from('task_assignments') .delete() .eq('task_id', taskId) .inFilter('user_id', toRemove); } _clearOfflineAssignment(taskId); } catch (e) { if (isOfflineSaveError(e)) { _trackOfflineAssignment(taskId, newUserIds); // Queue an activity log for the offline assignment change so it syncs // on reconnect and is visible in the offline UI immediately. final logRef = _ref; if (logRef != null) { final actorId = _client.auth.currentUser?.id; final logRow = { 'id': const Uuid().v4(), 'task_id': taskId, 'actor_id': actorId, 'action_type': toRemove.isEmpty ? 'assigned' : 'reassigned', 'meta': {'to': toAdd, 'from': toRemove}, 'created_at': AppTime.now().toIso8601String(), }; final pending = List>.from( logRef.read(offlinePendingActivityLogsProvider), ); pending.add(logRow); logRef.read(offlinePendingActivityLogsProvider.notifier).state = List.unmodifiable(pending); // Typed items provider — watched by taskActivityLogsProvider for // immediate UI update (synchronous, no Brick timing race). final logModel = TaskActivityLog.fromMap(logRow); final currentItems = List.from( logRef.read(offlinePendingActivityLogItemsProvider), ); currentItems.add(logModel); logRef.read(offlinePendingActivityLogItemsProvider.notifier).state = List.unmodifiable(currentItems); mirrorBatchToBrick( [logModel], tag: 'offline_assignment_log', ); } return; } rethrow; } // Best-effort side effects — these run AFTER the transactional block so // an offline error here cannot retrack the assignment as pending. unawaited(_safeLogAssignmentChange( taskId: taskId, toAdd: toAdd, toRemove: toRemove, )); if (toAdd.isNotEmpty) { unawaited(_safeNotifyAssigned( taskId: taskId, ticketId: ticketId, userIds: toAdd, )); } } Future _safeLogAssignmentChange({ required String taskId, required List toAdd, required List toRemove, }) async { try { final actorId = _client.auth.currentUser?.id; if (toAdd.isNotEmpty) { final logRows = toAdd .map( (userId) => { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'assigned', 'meta': {'user_id': userId}, }, ) .toList(); await _insertActivityRows(_client, logRows); } if (toRemove.isNotEmpty) { await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'reassigned', 'meta': {'from': toRemove, 'to': toAdd}, }); } } catch (_) { // non-fatal } } Future _safeNotifyAssigned({ required String taskId, required String? ticketId, required List userIds, }) async { try { await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: userIds); } catch (_) { // non-fatal — never let notify errors propagate } } Future _notifyAssigned({ required String taskId, required String? ticketId, required List userIds, }) async { if (userIds.isEmpty) return; try { final actorId = _client.auth.currentUser?.id; final rows = userIds .map( (userId) => { 'user_id': userId, 'actor_id': actorId, 'task_id': taskId, 'ticket_id': ticketId, 'type': 'assignment', }, ) .toList(); await _client.from('notifications').insert(rows); // send FCM pushes for explicit assignments try { String actorName = 'Someone'; if (actorId != null && actorId.isNotEmpty) { try { final p = await _client .from('profiles') .select('full_name,display_name,name') .eq('id', actorId) .maybeSingle(); if (p != null) { if (p['full_name'] != null) { actorName = p['full_name'].toString(); } else if (p['display_name'] != null) { actorName = p['display_name'].toString(); } else if (p['name'] != null) { actorName = p['name'].toString(); } } } catch (_) {} } // fetch task_number and office (try embedding office.name); fallback to offices lookup String? taskNumber; String? officeId; String? officeName; try { final t = await _client .from('tasks') .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); } if (t['office_id'] != null) officeId = t['office_id'].toString(); final dynOffices = t['offices']; if (dynOffices != null) { if (dynOffices is List && dynOffices.isNotEmpty && dynOffices.first['name'] != null) { officeName = dynOffices.first['name'].toString(); } else if (dynOffices is Map && dynOffices['name'] != null) { officeName = dynOffices['name'].toString(); } } } } catch (_) {} if ((officeName == null || officeName.isEmpty) && officeId != null && officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); if (o != null && o['name'] != null) { officeName = o['name'].toString(); } } catch (_) {} } final dataPayload = { 'type': 'assignment', 'task_id': taskId, 'task_number': ?taskNumber, 'ticket_id': ?ticketId, 'office_id': ?officeId, 'office_name': ?officeName, }; final title = 'Task assigned'; final body = taskNumber != null ? (officeName != null ? '$actorName assigned you task #$taskNumber in $officeName.' : '$actorName assigned you task #$taskNumber.') : (officeName != null ? '$actorName assigned you a task in $officeName.' : '$actorName assigned you a task.'); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': userIds, 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (e) { debugPrint('notifyAssigned push error: $e'); } } catch (_) { return; } } Future removeAssignment({ required String taskId, required String userId, }) async { try { await _client .from('task_assignments') .delete() .eq('task_id', taskId) .eq('user_id', userId); // Also drop from any pending entry so reconnect-replay doesn't re-add. _removeUserFromOfflinePending(taskId, userId); } catch (e) { if (isOfflineSaveError(e)) { _removeUserFromOfflinePending(taskId, userId); // If the user was a LIVE assignment (no pending entry yet), seed an // empty pending list so the reconnect-replay deletes ALL live rows // for this task and inserts none — effectively removing them. _ensureOfflineReplacementWithoutUser(taskId); return; } rethrow; } } void _removeUserFromOfflinePending(String taskId, String userId) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingAssignmentsProvider), ); final list = current[taskId]; if (list == null) return; final updated = list.where((u) => u != userId).toList(); if (updated.isEmpty) { // Keep the empty list as a "remove all" intent for the replay. current[taskId] = const []; } else { current[taskId] = updated; } ref.read(offlinePendingAssignmentsProvider.notifier).state = current; } void _ensureOfflineReplacementWithoutUser(String taskId) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingAssignmentsProvider), ); if (current.containsKey(taskId)) return; current[taskId] = const []; ref.read(offlinePendingAssignmentsProvider.notifier).state = current; } } // ─── Work Log helpers ──────────────────────────────────────────────────────── /// All task_assignments rows where user_id = [userId]. /// Used by the Work Log tab to identify which tasks the user is the assignee of. final taskAssignmentsForUserProvider = FutureProvider.autoDispose.family, String>( (ref, userId) async { try { final data = await Supabase.instance.client .from('task_assignments') .select() .eq('user_id', userId); return data.map(TaskAssignment.fromMap).toList(); } catch (_) { return []; } }, ); /// All task_activity_logs rows for the given task IDs — single batch query. /// Key is a sorted, comma-joined string of task IDs so Riverpod caches the /// provider correctly across rebuilds (List equality is reference-based in Dart). /// Returns logs in DESCENDING order (newest first). final taskActivityLogsForTasksProvider = FutureProvider.autoDispose.family, String>( (ref, taskIdsKey) async { if (taskIdsKey.isEmpty) return []; final taskIds = taskIdsKey.split(','); try { final data = await Supabase.instance.client .from('task_activity_logs') .select() .inFilter('task_id', taskIds) .order('created_at', ascending: false); return data.map(TaskActivityLog.fromMap).toList(); } catch (_) { try { final all = await cachedListFromBrick(); final idSet = taskIds.toSet(); final filtered = all.where((l) => idSet.contains(l.taskId)).toList(); filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return filtered; } catch (_) { return []; } } }, );