Roadbound-BRR/lib/models/channels/operations_channel.dart

436 lines
14 KiB
Dart

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<String, dynamic> 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<String, dynamic> 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<OperationsStop> stops;
final List<OperationsScheduledStop> scheduledStops;
}
class OperationsDutySnapshot {
const OperationsDutySnapshot({
required this.dutyNumber,
required this.busWorkNumber,
required this.trips,
});
final String dutyNumber;
final String busWorkNumber;
final List<OperationsTripSnapshot> 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<OperationsTripSnapshot> trips;
final List<OperationsDutySnapshot> duties;
final List<OperationsScheduledStop> scheduledStops;
final Map<String, String> stopAliasesByRawName;
final List<OperationsStopAlias> 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<String, dynamic> 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<OperationsSnapshot> 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: <OperationsTripSnapshot>[],
duties: <OperationsDutySnapshot>[],
scheduledStops: <OperationsScheduledStop>[],
stopAliasesByRawName: <String, String>{},
stopAliases: <OperationsStopAlias>[],
);
}
final stopAliases = await listStopAliases();
final stopAliasesByRawName = _aliasesByRawStopName(stopAliases);
final aliasesByNormalizedName = <String, String>{};
final aliasSourceByNormalizedName = <String, String>{};
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 <OperationsTripSnapshot>[],
duties: const <OperationsDutySnapshot>[],
scheduledStops: const <OperationsScheduledStop>[],
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<dynamic> updateRows = const <dynamic>[];
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 = <String, Map<String, dynamic>>{};
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 = <String, List<OperationsScheduledStop>>{};
final allScheduledStops = <OperationsScheduledStop>[];
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, () => <OperationsScheduledStop>[])
.add(scheduledStop);
}
final snapshots = trips.map((trip) {
final scheduledStops =
scheduledStopsByTripId[trip.id] ?? const <OperationsScheduledStop>[];
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 = <String, List<OperationsTripSnapshot>>{};
for (final snapshot in snapshots) {
groupedByDuty
.putIfAbsent(snapshot.trip.dutyKey, () => <OperationsTripSnapshot>[])
.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<List<OperationsStopAlias>> listStopAliases() async {
final rows = await client
.from("operations_stop_aliases")
.select("raw_stop_name, alias_stop_name, source")
.eq("channel_id", id);
final aliases = <OperationsStopAlias>[];
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<String, String> _aliasesByRawStopName(List<OperationsStopAlias> aliases) {
final byRawStopName = <String, String>{};
for (final alias in aliases) {
byRawStopName[alias.rawStopName] = alias.aliasStopName;
}
return byRawStopName;
}
Future<void> 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");
}
}