Add version files and update imports for trip model; enhance error handling

This commit is contained in:
ImBenji
2026-03-27 21:17:56 +00:00
parent e41e14e252
commit 427bcadc77
89 changed files with 9455 additions and 395 deletions
+67
View File
@@ -0,0 +1,67 @@
import "package:supabase_flutter/supabase_flutter.dart";
enum ChannelKind { text, operations, voice, announcement, unknown }
abstract class BaseChannel {
BaseChannel({
required this.client,
required this.id,
required this.organizationId,
required this.name,
required this.description,
required this.slug,
required this.kind,
required this.isPrivate,
required this.position,
});
final SupabaseClient client;
final String id;
final String organizationId;
final String name;
final String description;
final String slug;
final ChannelKind kind;
final bool isPrivate;
final int position;
Map<String, dynamic> toJson() {
return {
"id": id,
"organization_id": organizationId,
"name": name,
"description": description,
"slug": slug,
"type": kind.name,
"is_private": isPrivate,
"position": position,
};
}
static ChannelKind channelKindFromApi(Object? rawType) {
final value = (rawType ?? "").toString().trim().toLowerCase();
switch (value) {
case "text":
return ChannelKind.text;
case "operations":
return ChannelKind.operations;
case "voice":
return ChannelKind.voice;
case "announcement":
return ChannelKind.announcement;
default:
return ChannelKind.unknown;
}
}
static Map<String, dynamic> asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return <String, dynamic>{};
}
static List<dynamic> asList(Object? value) {
if (value is List) return value;
return const <dynamic>[];
}
}
+436
View File
@@ -0,0 +1,436 @@
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");
}
}
+129
View File
@@ -0,0 +1,129 @@
import "package:bus_running_record/models/channels/base_channel.dart";
import "package:supabase_flutter/supabase_flutter.dart";
class TextChannelMessage {
const TextChannelMessage({
required this.id,
required this.channelId,
required this.authorUserId,
required this.content,
required this.createdAt,
});
final String id;
final String channelId;
final String authorUserId;
final String content;
final DateTime? createdAt;
factory TextChannelMessage.fromMap(Map<String, dynamic> map) {
return TextChannelMessage(
id: (map["id"] ?? "").toString(),
channelId: (map["channel_id"] ?? "").toString(),
authorUserId: (map["author_user_id"] ?? "").toString(),
content: (map["content"] ?? "").toString(),
createdAt: DateTime.tryParse((map["created_at"] ?? "").toString()),
);
}
Map<String, dynamic> toJson() {
return {
"id": id,
"channel_id": channelId,
"author_user_id": authorUserId,
"content": content,
"created_at": createdAt?.toIso8601String(),
};
}
}
class TextChannel extends BaseChannel {
TextChannel({
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.text);
factory TextChannel.fromApi({
required SupabaseClient client,
required Map<String, dynamic> map,
}) {
return TextChannel(
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<List<TextChannelMessage>> listMessages({int limit = 50}) async {
final rows = await client
.from("messages")
.select("id, channel_id, author_user_id, content, created_at")
.eq("channel_id", id)
.order("created_at", ascending: true)
.limit(limit);
return BaseChannel.asList(rows)
.map((row) => TextChannelMessage.fromMap(BaseChannel.asMap(row)))
.where((message) => message.id.isNotEmpty)
.toList();
}
Future<TextChannelMessage?> sendMessage(String content) async {
final trimmed = content.trim();
if (trimmed.isEmpty) return null;
final authorUserId = client.auth.currentUser?.id;
if (authorUserId == null || authorUserId.isEmpty) {
throw StateError("Cannot send message without an authenticated user.");
}
final inserted = await client
.from("messages")
.insert({
"channel_id": id,
"author_user_id": authorUserId,
"content": trimmed,
})
.select("id, channel_id, author_user_id, content, created_at")
.single();
return TextChannelMessage.fromMap(BaseChannel.asMap(inserted));
}
RealtimeChannel subscribeToMessages({
required void Function() onMessageChanged,
void Function(RealtimeSubscribeStatus status, Object? error)? onStatus,
}) {
final topic = "messages:$id";
return client.channel(topic)
..onPostgresChanges(
event: PostgresChangeEvent.all,
schema: "public",
table: "messages",
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: "channel_id",
value: id,
),
callback: (_) => onMessageChanged(),
)
..subscribe((status, [error]) {
onStatus?.call(status, error);
});
}
Future<void> unsubscribe(RealtimeChannel channel) async {
await client.removeChannel(channel);
}
}