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 '../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 'stream_recovery.dart'; final officesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); 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, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => 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); }); /// 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 == '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 { final data = await client.from('tickets').select(); return data.cast>().map(Ticket.fromMap).toList(); }, fromMap: Ticket.fromMap, ); ref.onDispose(wrapper.dispose); 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 { try { final data = await client.from('tickets').select(); final raw = data .cast>() .map(Ticket.fromMap) .toList(); 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. 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'); controller.addError(e); }, ); 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(); } // Sort newest first. `created_at` may be ISO strings or timestamps; // handle strings and numeric values. 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) => 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(), ); 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, ); 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, ); 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, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }, ); final ticketsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return TicketsController(client); }); class TicketsController { TicketsController(this._client); final SupabaseClient _client; Future createTicket({ required String subject, required String description, required String officeId, }) async { final actorId = _client.auth.currentUser?.id; final data = await _client .from('tickets') .insert({ 'subject': subject, 'description': description, 'office_id': officeId, 'creator_id': _client.auth.currentUser?.id, }) .select('id') .single(); final ticketId = data['id'] as String?; if (ticketId == null) return; unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId)); } 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 payload = { 'task_id': taskId, 'content': content, 'sender_id': _client.auth.currentUser?.id, }; if (ticketId != null) { payload['ticket_id'] = ticketId; } final data = await _client .from('ticket_messages') .insert(payload) .select() .single(); return TicketMessage.fromMap(data); } Future updateTicketStatus({ required String ticketId, required String status, }) async { await _client.from('tickets').update({'status': status}).eq('id', ticketId); // 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. 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; 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 (_) {} } } class OfficesController { OfficesController(this._client); final SupabaseClient _client; Future createOffice({required String name, String? serviceId}) async { final payload = {'name': name}; if (serviceId != null) payload['service_id'] = serviceId; await _client.from('offices').insert(payload); } 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); } }