import "package:bus_running_record/models/channels/base_channel.dart"; import "package:bus_running_record/models/operations/stop.dart"; import "package:supabase_flutter/supabase_flutter.dart"; class OperationsSchedule { const OperationsSchedule({ required this.id, required this.version, required this.sourceFileName, required this.uploadedAt, }); final String id; final int version; final String sourceFileName; final DateTime? uploadedAt; factory OperationsSchedule.fromMap(Map map) { return OperationsSchedule( id: (map["id"] ?? "").toString(), version: (map["version"] as num?)?.toInt() ?? 1, sourceFileName: (map["source_file_name"] ?? "").toString(), uploadedAt: DateTime.tryParse((map["uploaded_at"] ?? "").toString()), ); } } class OperationsTrip { const OperationsTrip({ required this.id, required this.scheduleId, required this.tripNumber, required this.dutyNumber, required this.busWorkNumber, required this.direction, required this.sortOrder, }); final String id; final String scheduleId; final String tripNumber; final String dutyNumber; final String busWorkNumber; final String direction; final int sortOrder; String get dutyKey => "$dutyNumber::$busWorkNumber"; factory OperationsTrip.fromMap(Map map) { return OperationsTrip( id: (map["id"] ?? "").toString(), scheduleId: (map["schedule_id"] ?? "").toString(), tripNumber: (map["trip_number"] ?? "").toString(), dutyNumber: (map["duty_number"] ?? "").toString(), busWorkNumber: (map["bus_work_number"] ?? "").toString(), direction: (map["direction"] ?? "").toString(), sortOrder: (map["sort_order"] as num?)?.toInt() ?? 0, ); } } class OperationsScheduledStop extends Stop { const OperationsScheduledStop({ required this.id, required this.tripId, required this.sequence, required super.name, required this.scheduledTime, super.alias, super.aliasSource, }); final String id; final String tripId; final int sequence; final String? scheduledTime; } class OperationsStopUpdate { const OperationsStopUpdate({ required this.status, required this.actualTime, required this.vehicleId, required this.notes, }); final String? status; final String? actualTime; final String? vehicleId; final String? notes; } class OperationsStop { const OperationsStop({required this.scheduledStop, this.update}); final OperationsScheduledStop scheduledStop; final OperationsStopUpdate? update; String get id => scheduledStop.id; String get tripId => scheduledStop.tripId; int get sequence => scheduledStop.sequence; String get name => scheduledStop.name; String get displayName => scheduledStop.displayName; String? get aliasSource => scheduledStop.aliasSource; String? get scheduledTime => scheduledStop.scheduledTime; String? get status => update?.status; String? get actualTime => update?.actualTime; String? get vehicleId => update?.vehicleId; String? get notes => update?.notes; } class OperationsTripSnapshot { const OperationsTripSnapshot({ required this.trip, required this.stops, required this.scheduledStops, }); final OperationsTrip trip; final List stops; final List scheduledStops; } class OperationsDutySnapshot { const OperationsDutySnapshot({ required this.dutyNumber, required this.busWorkNumber, required this.trips, }); final String dutyNumber; final String busWorkNumber; final List trips; } class OperationsStopAlias { const OperationsStopAlias({ required this.rawStopName, required this.aliasStopName, required this.source, }); final String rawStopName; final String aliasStopName; final String source; } class OperationsSnapshot { const OperationsSnapshot({ required this.schedule, required this.trips, required this.duties, required this.scheduledStops, required this.stopAliasesByRawName, required this.stopAliases, }); final OperationsSchedule? schedule; final List trips; final List duties; final List scheduledStops; final Map stopAliasesByRawName; final List stopAliases; } class OperationsChannel extends BaseChannel { OperationsChannel({ required super.client, required super.id, required super.organizationId, required super.name, required super.description, required super.slug, required super.isPrivate, required super.position, }) : super(kind: ChannelKind.operations); factory OperationsChannel.fromApi({ required SupabaseClient client, required Map map, }) { return OperationsChannel( client: client, id: (map["id"] ?? "").toString(), organizationId: (map["organization_id"] ?? "").toString(), name: (map["name"] ?? "").toString(), description: (map["description"] ?? map["topic"] ?? "").toString(), slug: (map["slug"] ?? "").toString(), isPrivate: map["is_private"] == true, position: (map["position"] as num?)?.toInt() ?? 0, ); } Future loadSnapshot() async { final scheduleRow = await client .from("operations_schedules") .select("id, version, source_file_name, uploaded_at") .eq("channel_id", id) .eq("is_active", true) .order("uploaded_at", ascending: false) .limit(1) .maybeSingle(); if (scheduleRow == null) { return const OperationsSnapshot( schedule: null, trips: [], duties: [], scheduledStops: [], stopAliasesByRawName: {}, stopAliases: [], ); } final stopAliases = await listStopAliases(); final stopAliasesByRawName = _aliasesByRawStopName(stopAliases); final aliasesByNormalizedName = {}; final aliasSourceByNormalizedName = {}; for (final entry in stopAliasesByRawName.entries) { aliasesByNormalizedName[Stop.normalizeName(entry.key)] = entry.value; } for (final alias in stopAliases) { aliasSourceByNormalizedName[Stop.normalizeName(alias.rawStopName)] = alias.source; } final schedule = OperationsSchedule.fromMap(BaseChannel.asMap(scheduleRow)); final tripRows = await client .from("operations_trips") .select( "id, schedule_id, trip_number, duty_number, bus_work_number, direction, sort_order", ) .eq("schedule_id", schedule.id) .order("sort_order", ascending: true); final trips = BaseChannel.asList( tripRows, ).map((row) => OperationsTrip.fromMap(BaseChannel.asMap(row))).toList(); if (trips.isEmpty) { return OperationsSnapshot( schedule: schedule, trips: const [], duties: const [], scheduledStops: const [], stopAliasesByRawName: stopAliasesByRawName, stopAliases: stopAliases, ); } final tripIds = trips.map((trip) => trip.id).toList(); final stopRows = await client .from("operations_trip_stops") .select("id, trip_id, stop_sequence, stop_name, scheduled_time") .filter("trip_id", "in", "(${tripIds.join(",")})") .order("trip_id", ascending: true) .order("stop_sequence", ascending: true); final stopIds = BaseChannel.asList(stopRows) .map((row) => (BaseChannel.asMap(row)["id"] ?? "").toString()) .where((value) => value.isNotEmpty) .toList(); List updateRows = const []; if (stopIds.isNotEmpty) { updateRows = await client .from("operations_stop_updates") .select("trip_stop_id, status, actual_time, vehicle_id, notes") .filter("trip_stop_id", "in", "(${stopIds.join(",")})"); } final updatesByStopId = >{}; for (final row in BaseChannel.asList(updateRows)) { final map = BaseChannel.asMap(row); final tripStopId = (map["trip_stop_id"] ?? "").toString(); if (tripStopId.isEmpty) continue; updatesByStopId[tripStopId] = map; } final scheduledStopsByTripId = >{}; final allScheduledStops = []; for (final row in BaseChannel.asList(stopRows)) { final map = BaseChannel.asMap(row); final stopId = (map["id"] ?? "").toString(); final tripId = (map["trip_id"] ?? "").toString(); final scheduledStop = OperationsScheduledStop( id: stopId, tripId: tripId, sequence: (map["stop_sequence"] as num?)?.toInt() ?? 0, name: (map["stop_name"] ?? "").toString().trim(), alias: aliasesByNormalizedName[ Stop.normalizeName((map["stop_name"] ?? "").toString())], aliasSource: aliasSourceByNormalizedName[ Stop.normalizeName((map["stop_name"] ?? "").toString())], scheduledTime: (map["scheduled_time"] ?? "").toString().trim().isEmpty ? null : (map["scheduled_time"] ?? "").toString(), ); allScheduledStops.add(scheduledStop); scheduledStopsByTripId .putIfAbsent(tripId, () => []) .add(scheduledStop); } final snapshots = trips.map((trip) { final scheduledStops = scheduledStopsByTripId[trip.id] ?? const []; final stops = scheduledStops .map((scheduledStop) { final update = updatesByStopId[scheduledStop.id]; final hasUpdate = update != null; return OperationsStop( scheduledStop: scheduledStop, update: !hasUpdate ? null : OperationsStopUpdate( status: (update["status"] ?? "").toString().trim().isEmpty ? null : (update["status"] ?? "").toString(), actualTime: (update["actual_time"] ?? "") .toString() .trim() .isEmpty ? null : (update["actual_time"] ?? "").toString(), vehicleId: (update["vehicle_id"] ?? "").toString().trim().isEmpty ? null : (update["vehicle_id"] ?? "").toString(), notes: (update["notes"] ?? "").toString().trim().isEmpty ? null : (update["notes"] ?? "").toString(), ), ); }) .toList(growable: false); return OperationsTripSnapshot( trip: trip, stops: stops, scheduledStops: scheduledStops, ); }).toList(); final groupedByDuty = >{}; for (final snapshot in snapshots) { groupedByDuty .putIfAbsent(snapshot.trip.dutyKey, () => []) .add(snapshot); } final duties = groupedByDuty.entries .map((entry) { final dutyTrips = entry.value; final firstTrip = dutyTrips.first.trip; return OperationsDutySnapshot( dutyNumber: firstTrip.dutyNumber, busWorkNumber: firstTrip.busWorkNumber, trips: dutyTrips, ); }) .toList(growable: false); return OperationsSnapshot( schedule: schedule, trips: snapshots, duties: duties, scheduledStops: allScheduledStops, stopAliasesByRawName: stopAliasesByRawName, stopAliases: stopAliases, ); } Future> listStopAliases() async { final rows = await client .from("operations_stop_aliases") .select("raw_stop_name, alias_stop_name, source") .eq("channel_id", id); final aliases = []; for (final row in BaseChannel.asList(rows)) { final map = BaseChannel.asMap(row); final raw = (map["raw_stop_name"] ?? "").toString().trim(); final alias = (map["alias_stop_name"] ?? "").toString().trim(); final source = (map["source"] ?? "").toString().trim(); if (raw.isEmpty || alias.isEmpty) continue; aliases.add( OperationsStopAlias( rawStopName: raw, aliasStopName: alias, source: source.isEmpty ? "user" : source, ), ); } return aliases; } Map _aliasesByRawStopName(List aliases) { final byRawStopName = {}; for (final alias in aliases) { byRawStopName[alias.rawStopName] = alias.aliasStopName; } return byRawStopName; } Future markStopCompleted({ required String tripStopId, required bool completed, String? actualTime, String? vehicleId, String? notes, }) async { final userId = client.auth.currentUser?.id; if (userId == null || userId.isEmpty) { throw StateError( "Cannot update stop status without an authenticated user.", ); } String? persistedActualTime = actualTime; if (completed && (persistedActualTime == null || persistedActualTime.isEmpty)) { final now = DateTime.now(); final hour = now.hour.toString().padLeft(2, "0"); final minute = now.minute.toString().padLeft(2, "0"); persistedActualTime = "$hour:$minute"; } await client.from("operations_stop_updates").upsert({ "trip_stop_id": tripStopId, "status": completed ? "completed" : null, "actual_time": completed ? persistedActualTime : null, "vehicle_id": vehicleId, "notes": notes, "updated_by": userId, }, onConflict: "trip_stop_id"); } }