import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; import '../brick/repository.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import '../models/office.dart'; import '../models/ticket.dart'; import '../models/ticket_message.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'user_offices_provider.dart'; import 'tasks_provider.dart'; import 'connectivity_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; /// Pending offices created while offline. final offlinePendingOfficesProvider = StateProvider>((ref) => const []); final officesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final pendingOffices = ref.watch(offlinePendingOfficesProvider); List applyPending(List rows) { final byId = {for (final r in rows) r.id: r}; for (final p in pendingOffices) { byId.putIfAbsent(p.id, () => p); } return byId.values.toList() ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } final wrapper = StreamRecoveryWrapper( stream: client.from('offices').stream(primaryKey: ['id']).order('name'), onPollData: () async { final data = await client.from('offices').select().order('name'); return data.map(Office.fromMap).toList(); }, fromMap: Office.fromMap, channelName: 'offices', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { final all = await cachedListFromBrick(); all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); return applyPending(all); }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'offices'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay for offline-created offices. ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; final pending = List.from( ref.read(offlinePendingOfficesProvider), ); if (pending.isEmpty) return; final synced = []; for (final office in pending) { try { final payload = {'id': office.id, 'name': office.name}; if (office.serviceId != null) payload['service_id'] = office.serviceId; await client.from('offices').insert(payload); synced.add(office.id); } catch (e) { final s = e.toString(); if (s.contains('23505') || s.contains('duplicate key')) { synced.add(office.id); } } } if (synced.isNotEmpty) { final remaining = ref .read(offlinePendingOfficesProvider) .where((o) => !synced.contains(o.id)) .toList(); ref.read(offlinePendingOfficesProvider.notifier).state = List.unmodifiable(remaining); } }); return wrapper.stream.map((result) => applyPending(result.data)); }); final officesOnceProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); final rows = await client.from('offices').select().order('name'); return (rows as List) .map((row) => Office.fromMap(row as Map)) .toList(); }); /// Office query parameters for server-side pagination. class OfficeQuery { /// Creates office query parameters. const OfficeQuery({this.offset = 0, this.limit = 50, this.searchQuery = ''}); /// Offset for pagination. final int offset; /// Number of items per page (default: 50). final int limit; /// Full text search query. final String searchQuery; OfficeQuery copyWith({int? offset, int? limit, String? searchQuery}) { return OfficeQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, searchQuery: searchQuery ?? this.searchQuery, ); } } final officesQueryProvider = StateProvider( (ref) => const OfficeQuery(), ); final officesControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return OfficesController(client, ref); }); /// Ticket query parameters for server-side pagination and filtering. class TicketQuery { /// Creates ticket query parameters. const TicketQuery({ this.offset = 0, this.limit = 50, this.searchQuery = '', this.officeId, this.status, 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 date range. /// Filter by date range. final DateTimeRange? dateRange; TicketQuery copyWith({ int? offset, int? limit, String? searchQuery, String? officeId, String? status, DateTimeRange? dateRange, }) { return TicketQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, searchQuery: searchQuery ?? this.searchQuery, officeId: officeId ?? this.officeId, status: status ?? this.status, dateRange: dateRange ?? this.dateRange, ); } } /// Builds the isolate payload from a list of [Ticket] 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 _buildTicketPayload({ required List tickets, required bool isGlobal, required List allowedOfficeIds, required TicketQuery query, }) { final rowsList = tickets .map( (ticket) => { 'id': ticket.id, 'subject': ticket.subject, 'description': ticket.description, 'status': ticket.status, 'office_id': ticket.officeId, 'creator_id': ticket.creatorId, 'created_at': ticket.createdAt.toIso8601String(), 'responded_at': ticket.respondedAt?.toIso8601String(), 'promoted_at': ticket.promotedAt?.toIso8601String(), 'closed_at': ticket.closedAt?.toIso8601String(), }, ) .toList(); return { 'rows': rowsList, 'isGlobal': isGlobal, 'allowedOfficeIds': allowedOfficeIds, 'offset': query.offset, 'limit': query.limit, 'searchQuery': query.searchQuery, 'officeId': query.officeId, 'status': query.status, 'dateStart': query.dateRange?.start.millisecondsSinceEpoch, 'dateEnd': query.dateRange?.end.millisecondsSinceEpoch, }; } final ticketsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final profileAsync = ref.watch(currentProfileProvider); final assignmentsAsync = ref.watch(userOfficesProvider); final query = ref.watch(ticketsQueryProvider); 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 allowedOfficeIds = assignmentsAsync.valueOrNull ?.where((a) => a.userId == profile.id) .map((a) => a.officeId) .toList() ?? []; // Wrap realtime stream with recovery logic final wrapper = StreamRecoveryWrapper( stream: client.from('tickets').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('tickets').select(); if (query.status != null) q = q.eq('status', query.status!); if (query.officeId != null) q = q.eq('office_id', query.officeId!); if (query.searchQuery.isNotEmpty) { q = q.or( 'subject.ilike.%${query.searchQuery}%,' 'description.ilike.%${query.searchQuery}%', ); } final data = await q .order('created_at', ascending: false) .range(0, kTaskPageLimit - 1); return data.cast>().map(Ticket.fromMap).toList(); }, fromMap: Ticket.fromMap, channelName: 'tickets', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () => cachedListFromBrick(), onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'tickets'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay: POST queued items directly, bypassing Brick's queue. ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; // 1. Replay offline-created tickets final pendingRaw = List>.from( ref.read(offlinePendingTicketRawProvider), ); if (pendingRaw.isNotEmpty) { debugPrint( '[ticketsProvider] reconnected — syncing ${pendingRaw.length} ticket(s)', ); final syncedIds = []; for (final row in pendingRaw) { final id = row['id'] as String; try { await client.from('tickets').insert(row); syncedIds.add(id); } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { syncedIds.add(id); } else { debugPrint('[ticketsProvider] failed to sync ticket id=$id: $e'); } } } if (syncedIds.isNotEmpty) { final remainingRaw = ref .read(offlinePendingTicketRawProvider) .where((r) => !syncedIds.contains(r['id'] as String)) .toList(); ref.read(offlinePendingTicketRawProvider.notifier).state = List.unmodifiable(remainingRaw); final remainingModels = ref .read(offlinePendingTicketsProvider) .where((t) => !syncedIds.contains(t.id)) .toList(); ref.read(offlinePendingTicketsProvider.notifier).state = List.unmodifiable(remainingModels); } } // 2. Replay offline ticket updates final pendingUpdates = Map>.from( ref.read(offlinePendingTicketUpdatesProvider), ); if (pendingUpdates.isNotEmpty) { debugPrint( '[ticketsProvider] reconnected — syncing ${pendingUpdates.length} ticket update(s)', ); final syncedIds = []; for (final entry in pendingUpdates.entries) { try { await client .from('tickets') .update(entry.value) .eq('id', entry.key); syncedIds.add(entry.key); } catch (e) { debugPrint( '[ticketsProvider] failed to sync update id=${entry.key}: $e', ); } } if (syncedIds.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingTicketUpdatesProvider), ); for (final id in syncedIds) { remaining.remove(id); } ref.read(offlinePendingTicketUpdatesProvider.notifier).state = remaining; } } }); var lastResultHash = ''; Timer? debounceTimer; // broadcast() so Riverpod and any other listener can both receive events. final controller = StreamController>.broadcast(); void emitDebounced(List tickets) { debounceTimer?.cancel(); debounceTimer = Timer(const Duration(milliseconds: 150), () { if (!controller.isClosed) controller.add(tickets); }); } 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. This eliminates the // loading delay on web (WebSocket ~200-500 ms) and the initial flash on // mobile. The realtime stream takes over afterwards; the hash check below // prevents a duplicate rebuild if both arrive with identical data. unawaited( Future(() async { // When offline, seed from Brick local SQLite cache instead of network. if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) { try { final cached = await AppRepository.instance.get( policy: OfflineFirstGetPolicy.localOnly, ); debugPrint('[ticketsProvider] offline seed: ${cached.length} tickets from local cache'); final payload = _buildTicketPayload( tickets: cached, isGlobal: isGlobal, allowedOfficeIds: allowedOfficeIds, query: query, ); final processed = await compute(_processTicketsInIsolate, payload); final tickets = (processed as List) .cast>() .map(Ticket.fromMap) .toList(); final hash = tickets.fold('', (h, t) => '$h${t.id}'); if (!controller.isClosed && hash != lastResultHash) { lastResultHash = hash; controller.add(tickets); } } catch (e) { debugPrint('[ticketsProvider] 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('tickets').select(); if (query.status != null) seedQ = seedQ.eq('status', query.status!); if (query.officeId != null) { seedQ = seedQ.eq('office_id', query.officeId!); } final data = await seedQ .order('created_at', ascending: false) .range(0, kTaskPageLimit - 1); final raw = data .cast>() .map(Ticket.fromMap) .toList(); if (raw.length >= kTaskPageLimit) { debugPrint( '[ticketsProvider] seed hit $kTaskPageLimit-row limit; ' 'older tickets arrive via realtime stream.', ); } final payload = _buildTicketPayload( tickets: raw, isGlobal: isGlobal, allowedOfficeIds: allowedOfficeIds, query: query, ); final processed = await compute(_processTicketsInIsolate, payload); final tickets = (processed as List) .cast>() .map(Ticket.fromMap) .toList(); final hash = tickets.fold('', (h, t) => '$h${t.id}'); if (!controller.isClosed && hash != lastResultHash) { lastResultHash = hash; controller.add(tickets); // emit immediately – no debounce } } catch (e) { debugPrint('[ticketsProvider] 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 = _buildTicketPayload( tickets: result.data, isGlobal: isGlobal, allowedOfficeIds: allowedOfficeIds, query: query, ); final processed = await compute(_processTicketsInIsolate, payload); return (processed as List) .cast>() .map(Ticket.fromMap) .toList(); }) .listen( (tickets) { final hash = tickets.fold('', (h, t) => '$h${t.id}'); if (hash != lastResultHash) { lastResultHash = hash; emitDebounced(tickets); } }, onError: (Object e) { debugPrint('[ticketsProvider] stream error: $e'); // Don't forward errors — the wrapper handles recovery internally. }, ); ref.onDispose(wrapperSub.cancel); return controller.stream; }); // Runs inside a background isolate. Accepts a serializable payload and // returns a list of ticket maps after filtering/sorting/pagination. List> _processTicketsInIsolate( Map payload, ) { final rows = (payload['rows'] as List).cast>(); var list = List>.from(rows); final isGlobal = payload['isGlobal'] as bool? ?? false; final allowedOfficeIds = (payload['allowedOfficeIds'] as List?)?.cast().toSet() ?? {}; if (!isGlobal) { if (allowedOfficeIds.isEmpty) { return >[]; } list = list .where((t) => allowedOfficeIds.contains(t['office_id'])) .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 subj = (t['subject'] as String?)?.toLowerCase() ?? ''; final desc = (t['description'] as String?)?.toLowerCase() ?? ''; return subj.contains(q) || desc.contains(q); }).toList(); } // Parse created_at timestamp from map. `created_at` may be ISO strings or timestamps. 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; } int statusRank(String s) { switch (s) { case 'pending': return 0; case 'promoted': return 1; case 'closed': return 2; default: return 3; } } // Sort by status rank first (pending → promoted → closed), // then by created_at descending (newest first) 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; // Same status: sort by created_at descending (newest first) return parseCreatedAt(b).compareTo(parseCreatedAt(a)); }); final start = (payload['offset'] as int?) ?? 0; final limit = (payload['limit'] as int?) ?? 50; final end = (start + limit).clamp(0, list.length); if (start >= list.length) return >[]; return list.sublist(start, end); } /// Provider for ticket query parameters. final ticketsQueryProvider = StateProvider( (ref) => const TicketQuery(), ); /// Derived provider that selects a single [Ticket] by ID from the tickets list. /// /// Because [Ticket] implements `==`, this provider only notifies watchers when /// the specific ticket's data actually changes — not when unrelated tickets in /// the list are updated. Use this in detail screens to avoid full-list rebuilds. final ticketByIdProvider = Provider.family((ref, ticketId) { return ref .watch(ticketsProvider) .valueOrNull ?.where((t) => t.id == ticketId) .firstOrNull; }); final ticketMessagesProvider = StreamProvider.family, String>((ref, ticketId) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('ticket_messages') .stream(primaryKey: ['id']) .eq('ticket_id', ticketId) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('ticket_messages') .select() .eq('ticket_id', ticketId) .order('created_at', ascending: false); return data.map(TicketMessage.fromMap).toList(); }, fromMap: TicketMessage.fromMap, channelName: 'ticket_messages:$ticketId', onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); final ticketMessagesAllProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('ticket_messages') .stream(primaryKey: ['id']) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('ticket_messages') .select() .order('created_at', ascending: false); return data.map(TicketMessage.fromMap).toList(); }, fromMap: TicketMessage.fromMap, channelName: 'ticket_messages_all', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); final taskMessagesProvider = StreamProvider.family, String>( (ref, taskId) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('ticket_messages') .stream(primaryKey: ['id']) .eq('task_id', taskId) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('ticket_messages') .select() .eq('task_id', taskId) .order('created_at', ascending: false); return data.map(TicketMessage.fromMap).toList(); }, fromMap: TicketMessage.fromMap, channelName: 'task_messages:$taskId', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }, ); /// Tickets created offline that haven't synced to Supabase yet. /// Merged into the live stream in the UI — cleared entry-by-entry as the /// realtime stream delivers each synced UUID. final offlinePendingTicketsProvider = StateProvider>((ref) => const []); /// Raw INSERT payloads for offline-created tickets — used for reconnect replay. final offlinePendingTicketRawProvider = StateProvider>>((ref) => const []); /// Field-level updates to existing tickets made offline, keyed by ticket id. final offlinePendingTicketUpdatesProvider = StateProvider>>((ref) => const {}); final ticketsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return TicketsController(client, ref); }); class TicketsController { TicketsController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; /// Creates a ticket. Returns the new [Ticket] so callers can add it to /// [offlinePendingTicketsProvider] when offline. Future createTicket({ required String subject, required String description, required String officeId, }) async { final actorId = _client.auth.currentUser?.id; // 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 id = const Uuid().v4(); try { final data = await _client .from('tickets') .insert({ 'id': id, 'subject': subject, 'description': description, 'office_id': officeId, 'creator_id': actorId, }) .select('id') .single(); final ticketId = data['id'] as String?; if (ticketId == null) return null; unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId)); return null; // online path — ticket will arrive via realtime stream } catch (e) { if (!isOfflineSaveError(e)) rethrow; final ticket = Ticket( id: id, subject: subject, description: description, officeId: officeId, creatorId: actorId ?? '', status: 'pending', createdAt: DateTime.now().toUtc(), respondedAt: null, promotedAt: null, closedAt: null, ); // Queue for reconnect replay and persist locally for cold-launch. final ref = _ref; if (ref != null) { final currentRaw = List>.from( ref.read(offlinePendingTicketRawProvider), ); currentRaw.add({ 'id': id, 'subject': subject, 'description': description, 'office_id': officeId, 'creator_id': actorId, }); ref.read(offlinePendingTicketRawProvider.notifier).state = List.unmodifiable(currentRaw); } mirrorBatchToBrick([ticket], tag: 'offline_ticket'); return ticket; } } Future _notifyCreated({ required String ticketId, 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, 'ticket_id': ticketId, 'type': 'created', }, ) .toList(); await _client.from('notifications').insert(rows); // Send FCM pushes for ticket creation 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 (_) {} } String? ticketNumber; try { final t = await _client .from('tickets') .select('ticket_number') .eq('id', ticketId) .maybeSingle(); if (t != null && t['ticket_number'] != null) { ticketNumber = t['ticket_number'].toString(); } } catch (_) {} final title = '$actorName created a new ticket'; final body = ticketNumber != null ? '$actorName created ticket #$ticketNumber' : '$actorName created a new ticket'; await _client.functions.invoke( 'send_fcm', body: { 'user_ids': recipients, 'title': title, 'body': body, 'data': { 'ticket_id': ticketId, 'ticket_number': ?ticketNumber, 'type': 'created', }, }, ); } catch (e) { // non-fatal debugPrint('ticket 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 as List; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } Future sendTicketMessage({ required String ticketId, required String content, }) async { final data = await _client .from('ticket_messages') .insert({ 'ticket_id': ticketId, 'content': content, 'sender_id': _client.auth.currentUser?.id, }) .select() .single(); return TicketMessage.fromMap(data); } Future sendTaskMessage({ required String taskId, required String? ticketId, required String content, }) async { final senderId = _client.auth.currentUser?.id; final payload = { 'task_id': taskId, 'content': content, 'sender_id': senderId, }; if (ticketId != null) { payload['ticket_id'] = ticketId; } try { final data = await _client .from('ticket_messages') .insert(payload) .select() .single(); return TicketMessage.fromMap(data); } catch (e) { if (isOfflineSaveError(e)) { // Store message locally so it appears in the UI immediately and is // replayed by tasksProvider's reconnect listener when back online. final ref = _ref; if (ref != null) { final createdAt = DateTime.now().toUtc().toIso8601String(); final msgPayload = Map.from(payload) ..['created_at'] = createdAt; final current = Map>>.from( ref.read(offlinePendingMessagesProvider), ); current[taskId] = [...(current[taskId] ?? []), msgPayload]; ref.read(offlinePendingMessagesProvider.notifier).state = current; // Return a synthetic TicketMessage so the caller's mention logic // still has a message object to work with. return TicketMessage( id: -DateTime.now().millisecondsSinceEpoch, ticketId: ticketId, taskId: taskId, senderId: senderId, content: content, createdAt: DateTime.now(), ); } } rethrow; } } Future updateTicketStatus({ required String ticketId, required String status, }) async { try { await _client .from('tickets') .update({'status': status}) .eq('id', ticketId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueTicketUpdate(ticketId, {'status': status}); return; } // If ticket is promoted, create a linked Task (only once) — the // TasksController.createTask already runs auto-assignment on creation. if (status == 'promoted') { try { final existing = await _client .from('tasks') .select('id') .eq('ticket_id', ticketId) .maybeSingle(); if (existing != null) return; final ticketRow = await _client .from('tickets') .select('subject, description, office_id') .eq('id', ticketId) .maybeSingle(); final title = (ticketRow?['subject'] as String?) ?? 'Task from ticket'; final description = (ticketRow?['description'] as String?) ?? ''; final officeId = ticketRow?['office_id'] as String?; final tasksCtrl = TasksController(_client); await tasksCtrl.createTask( title: title, description: description, officeId: officeId, ticketId: ticketId, ); } catch (_) { // best-effort — don't fail the ticket status update } } } /// Update editable ticket fields such as subject, description, and office. /// Offline: queues the field patch. Future updateTicket({ required String ticketId, String? subject, String? description, String? officeId, }) async { final payload = {}; if (subject != null) payload['subject'] = subject; if (description != null) payload['description'] = description; if (officeId != null) payload['office_id'] = officeId; if (payload.isEmpty) return; try { await _client.from('tickets').update(payload).eq('id', ticketId); // record an activity row for edit operations (best-effort) try { final actorId = _client.auth.currentUser?.id; await _client.from('ticket_messages').insert({ 'ticket_id': ticketId, 'sender_id': actorId, 'content': 'Ticket updated', }); } catch (_) {} } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueTicketUpdate(ticketId, payload); } } void _queueTicketUpdate(String ticketId, Map fields) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingTicketUpdatesProvider), ); current[ticketId] = {...?current[ticketId], ...fields}; ref.read(offlinePendingTicketUpdatesProvider.notifier).state = current; } } class OfficesController { OfficesController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; Future createOffice({required String name, String? serviceId}) async { final id = const Uuid().v4(); final payload = {'id': id, 'name': name}; if (serviceId != null) payload['service_id'] = serviceId; try { await _client.from('offices').insert(payload); } catch (e) { if (!isOfflineSaveError(e)) rethrow; final ref = _ref; if (ref == null) return; final office = Office(id: id, name: name, serviceId: serviceId); final current = List.from( ref.read(offlinePendingOfficesProvider), ); current.add(office); ref.read(offlinePendingOfficesProvider.notifier).state = List.unmodifiable(current); mirrorBatchToBrick([office], tag: 'offline_offices'); } } Future updateOffice({ required String id, required String name, String? serviceId, }) async { final payload = {'name': name}; if (serviceId != null) payload['service_id'] = serviceId; await _client.from('offices').update(payload).eq('id', id); } Future deleteOffice({required String id}) async { await _client.from('offices').delete().eq('id', id); } }