Enhance operations configuration and UI; add new functions for schedule upload and duty filtering
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:math";
|
||||
|
||||
import "package:bus_running_record/models/operations/scheduled_stop.dart";
|
||||
@@ -74,12 +76,17 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
throw StateError("No trips parsed from schedule.");
|
||||
}
|
||||
if (!mounted) return;
|
||||
final duties = trips.map((t) => t.dutyNumber).toSet().toList()..sort();
|
||||
final firstDuty = duties.isNotEmpty ? duties.first : null;
|
||||
|
||||
setState(() {
|
||||
_parsedTrips = trips;
|
||||
_fileName = file.name;
|
||||
_parserType = parserType;
|
||||
_sourceMime = sourceMime;
|
||||
_enhanced = false;
|
||||
filterBy = "By Duty";
|
||||
if (firstDuty != null) filterValue["By Duty"] = firstDuty;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[OperationsUploadPage] parse failed: $error");
|
||||
@@ -131,87 +138,64 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
final userId = client.auth.currentUser?.id;
|
||||
if (userId == null || userId.isEmpty) {
|
||||
throw StateError("No authenticated user.");
|
||||
}
|
||||
|
||||
final current = await client
|
||||
.from("operations_schedules")
|
||||
.select("version")
|
||||
.eq("channel_id", widget.channelId)
|
||||
.order("version", ascending: false)
|
||||
.limit(1);
|
||||
final latestVersion = (current as List).isEmpty
|
||||
? 0
|
||||
: (((current.first as Map)["version"] as num?)?.toInt() ?? 0);
|
||||
final nextVersion = latestVersion + 1;
|
||||
|
||||
await client
|
||||
.from("operations_schedules")
|
||||
.update({"is_active": false})
|
||||
.eq("channel_id", widget.channelId)
|
||||
.eq("is_active", true);
|
||||
|
||||
final scheduleRow = await client
|
||||
.from("operations_schedules")
|
||||
.insert({
|
||||
"channel_id": widget.channelId,
|
||||
"version": nextVersion,
|
||||
"source_file_name": fileName,
|
||||
"source_mime": sourceMime,
|
||||
"parser": parserType,
|
||||
"parse_status": "parsed",
|
||||
"uploaded_by": userId,
|
||||
"is_active": true,
|
||||
"parsed_at": DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
final scheduleId = (scheduleRow["id"] ?? "").toString();
|
||||
if (scheduleId.isEmpty) throw StateError("Created schedule missing id.");
|
||||
|
||||
final sortedTrips = _sortedTrips();
|
||||
|
||||
final tripPayloads = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < sortedTrips.length; i++) {
|
||||
final trip = sortedTrips[i];
|
||||
final tripRow = await client
|
||||
.from("operations_trips")
|
||||
.insert({
|
||||
"schedule_id": scheduleId,
|
||||
"trip_number": trip.tripNumber,
|
||||
"duty_number": trip.dutyNumber,
|
||||
"bus_work_number": trip.busWorkNumber,
|
||||
"direction": trip.direction,
|
||||
"service_code": trip.tripType,
|
||||
"sort_order": i,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
final tripId = (tripRow["id"] ?? "").toString();
|
||||
if (tripId.isEmpty) continue;
|
||||
|
||||
final stopRows = <Map<String, dynamic>>[];
|
||||
final stationOrder = trip.stationOrder.isEmpty
|
||||
? trip.stationTimes.keys.toList()
|
||||
: trip.stationOrder;
|
||||
|
||||
final stops = <Map<String, dynamic>>[];
|
||||
for (var s = 0; s < stationOrder.length; s++) {
|
||||
final stopName = stationOrder[s].trim();
|
||||
if (stopName.isEmpty) continue;
|
||||
final scheduled = (trip.stationTimes[stopName] ?? "").trim();
|
||||
stopRows.add({
|
||||
"trip_id": tripId,
|
||||
stops.add({
|
||||
"stop_sequence": s + 1,
|
||||
"stop_name": stopName,
|
||||
"scheduled_time": scheduled.isEmpty ? null : scheduled,
|
||||
});
|
||||
}
|
||||
if (stopRows.isNotEmpty) {
|
||||
await client.from("operations_trip_stops").insert(stopRows);
|
||||
}
|
||||
|
||||
tripPayloads.add({
|
||||
"trip_number": trip.tripNumber,
|
||||
"duty_number": trip.dutyNumber,
|
||||
"bus_work_number": trip.busWorkNumber,
|
||||
"direction": trip.direction,
|
||||
"service_code": trip.tripType,
|
||||
"sort_order": i,
|
||||
"stops": stops,
|
||||
});
|
||||
}
|
||||
|
||||
final bodyJson = {
|
||||
"channel_id": widget.channelId,
|
||||
"file_name": fileName,
|
||||
"source_mime": sourceMime,
|
||||
"parser": parserType,
|
||||
"trips": tripPayloads,
|
||||
};
|
||||
|
||||
final jsonBytes = utf8.encode(jsonEncode(bodyJson));
|
||||
final compressed = GZipCodec().encode(jsonBytes);
|
||||
|
||||
final response = await _invokeAuthedFunctionRaw(
|
||||
"operations-schedule-upload",
|
||||
body: Uint8List.fromList(compressed),
|
||||
extraHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data is Map && data["error"] != null) {
|
||||
throw StateError(data["error"].toString());
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -304,6 +288,46 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _invokeAuthedFunctionRaw(
|
||||
String functionName, {
|
||||
required Uint8List body,
|
||||
Map<String, String> extraHeaders = const {},
|
||||
}) async {
|
||||
var token = await _getFreshAccessToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw StateError("No valid access token available for edge function call.");
|
||||
}
|
||||
|
||||
final client = context.read<SupabaseProvider>().client;
|
||||
|
||||
Future<dynamic> invokeOnce(String accessToken) {
|
||||
client.functions.setAuth(accessToken);
|
||||
return client.functions.invoke(
|
||||
functionName,
|
||||
body: body,
|
||||
headers: {
|
||||
...extraHeaders,
|
||||
"Authorization": "Bearer $accessToken",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await invokeOnce(token);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
"[OperationsUploadPage] invokeAuthedFunctionRaw/$functionName failed: $error",
|
||||
);
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
if (!_isUnauthorizedFunctionError(error)) rethrow;
|
||||
|
||||
final refreshed = await client.auth.refreshSession();
|
||||
token = refreshed.session?.accessToken ?? client.auth.currentSession?.accessToken;
|
||||
if (token == null || token.isEmpty) rethrow;
|
||||
return invokeOnce(token);
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _invokeAuthedFunction(
|
||||
String functionName, {
|
||||
Object? body,
|
||||
@@ -458,7 +482,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String? filterBy;
|
||||
String? filterBy = "By Duty";
|
||||
Map<String, Object?> filterValue = {};
|
||||
|
||||
Trip? _selectedTripForFilter() {
|
||||
@@ -473,6 +497,99 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Trip> _selectedDutyTripsForFilter() {
|
||||
if (filterBy != "By Duty") return const <Trip>[];
|
||||
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
|
||||
if (selectedDuty.isEmpty) return const <Trip>[];
|
||||
final trips = _parsedTrips
|
||||
.where((trip) => trip.dutyNumber == selectedDuty)
|
||||
.toList(growable: false);
|
||||
final sorted = List<Trip>.from(trips);
|
||||
sorted.sort((a, b) {
|
||||
final byTime = a.scheduledTime.compareTo(b.scheduledTime);
|
||||
if (byTime != 0) return byTime;
|
||||
final aNum = int.tryParse(a.tripNumber);
|
||||
final bNum = int.tryParse(b.tripNumber);
|
||||
if (aNum != null && bNum != null) return aNum.compareTo(bNum);
|
||||
return a.tripNumber.compareTo(b.tripNumber);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
String? _selectedStopForFilter() {
|
||||
if (filterBy != "By Stop") return null;
|
||||
final selectedStop = (filterValue["By Stop"] ?? "").toString().trim();
|
||||
if (selectedStop.isEmpty) return null;
|
||||
return selectedStop;
|
||||
}
|
||||
|
||||
List<({Trip trip, String? time})> _selectedStopSchedules(String stopName) {
|
||||
final rows = <({Trip trip, String? time})>[];
|
||||
for (final trip in _parsedTrips) {
|
||||
String? scheduledTime;
|
||||
for (final stop in trip.scheduledStops) {
|
||||
if (stop.name != stopName) continue;
|
||||
scheduledTime = stop.scheduledTime;
|
||||
break;
|
||||
}
|
||||
if (scheduledTime == null || scheduledTime.trim().isEmpty) continue;
|
||||
rows.add((trip: trip, time: scheduledTime));
|
||||
}
|
||||
|
||||
rows.sort((a, b) {
|
||||
final aNum = int.tryParse(a.trip.tripNumber);
|
||||
final bNum = int.tryParse(b.trip.tripNumber);
|
||||
if (aNum != null && bNum != null) {
|
||||
final byNum = aNum.compareTo(bNum);
|
||||
if (byNum != 0) return byNum;
|
||||
} else {
|
||||
final byText = a.trip.tripNumber.compareTo(b.trip.tripNumber);
|
||||
if (byText != 0) return byText;
|
||||
}
|
||||
final byDuty = a.trip.dutyNumber.compareTo(b.trip.dutyNumber);
|
||||
if (byDuty != 0) return byDuty;
|
||||
return (a.time ?? "").compareTo(b.time ?? "");
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
String? _aliasForStopName(String rawStopName) {
|
||||
final normalizedRaw = rawStopName.trim();
|
||||
if (normalizedRaw.isEmpty) return null;
|
||||
for (final trip in _parsedTrips) {
|
||||
for (final stop in trip.scheduledStops) {
|
||||
if (stop.name != normalizedRaw) continue;
|
||||
final alias = (stop.alias ?? "").trim();
|
||||
if (alias.isNotEmpty) return alias;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _stopDisplayLabel(String rawStopName) {
|
||||
final alias = _aliasForStopName(rawStopName);
|
||||
if (alias == null || alias.isEmpty) return Text(rawStopName);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
alias,
|
||||
).semiBold,
|
||||
|
||||
if (alias != rawStopName)
|
||||
Text(
|
||||
rawStopName,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
height: 1
|
||||
),
|
||||
).xSmall.muted.semiBold,
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<TripDiagramEntry> _tripDiagramEntries(Trip trip) {
|
||||
final orderedStops = List<ScheduledStop>.from(trip.scheduledStops)
|
||||
..sort((a, b) => a.sequence.compareTo(b.sequence));
|
||||
@@ -497,16 +614,211 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final selectedTrip = _selectedTripForFilter();
|
||||
if (selectedTrip == null) {
|
||||
return Text(
|
||||
"Select 'By Trip' and choose a trip to preview it.",
|
||||
).small.muted;
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final selectedStop = _selectedStopForFilter();
|
||||
if (filterBy == "By Stop" && selectedStop != null) {
|
||||
final schedules = _selectedStopSchedules(selectedStop);
|
||||
final selectedStopAlias = _aliasForStopName(selectedStop);
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.pin),
|
||||
Gap(8),
|
||||
_stopDisplayLabel(selectedStop),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
if (schedules.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
"No scheduled times found for this stop.",
|
||||
).small.muted,
|
||||
)
|
||||
else
|
||||
...schedules.map(
|
||||
(row) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.07),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Text(
|
||||
// "${row.trip.dutyNumber} • Trip ${row.trip.tripNumber} • Work ${row.trip.busWorkNumber}",
|
||||
// ).extraBold,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"${row.trip.dutyNumber} • ",
|
||||
).extraBold,
|
||||
// Gap(),
|
||||
Text(
|
||||
"Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).muted,
|
||||
],
|
||||
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"${row.trip.scheduledStops.last.displayName}",
|
||||
).muted.textSmall,
|
||||
if (row.trip.direction.toLowerCase().contains("out"))...[
|
||||
Gap(4),
|
||||
Icon(
|
||||
LucideIcons.arrowUpRight,
|
||||
).iconSmall
|
||||
]
|
||||
else if (row.trip.direction.toLowerCase().contains("in"))...[
|
||||
Gap(4),
|
||||
Icon(
|
||||
LucideIcons.arrowDownLeft,
|
||||
).iconSmall
|
||||
]
|
||||
],
|
||||
),
|
||||
|
||||
// Text(
|
||||
// "Trip ${row.trip.tripNumber}",
|
||||
// ).textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
row.time ?? "--:--",
|
||||
).extraBold.mono,
|
||||
|
||||
Gap(2),
|
||||
|
||||
Icon(
|
||||
LucideIcons.chevronRight
|
||||
),
|
||||
|
||||
Gap(2)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final selectedDutyTrips = _selectedDutyTripsForFilter();
|
||||
if (filterBy == "By Duty" && selectedDutyTrips.isNotEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Gap(8),
|
||||
Divider(),
|
||||
Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.busFront),
|
||||
Gap(10),
|
||||
Text(
|
||||
"Duty ${selectedDutyTrips.first.dutyNumber}".toUpperCase(),
|
||||
).extraBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < selectedDutyTrips.length; i++) ...[
|
||||
|
||||
if (i != 0)
|
||||
...[
|
||||
|
||||
Divider(),
|
||||
Gap(2),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bus),
|
||||
Gap(8),
|
||||
Text(
|
||||
"Trip ${selectedDutyTrips[i].tripNumber} • Duty ${selectedDutyTrips[i].dutyNumber}",
|
||||
).semiBold.textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
TripDiagram(
|
||||
lineColor: Colors.red,
|
||||
entries: _tripDiagramEntries(selectedDutyTrips[i]),
|
||||
leftOffset: 12,
|
||||
rowHeight: 48,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final selectedTrip = _selectedTripForFilter();
|
||||
if (selectedTrip == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Select 'By Trip' or 'By Duty' and choose a value to preview it.",
|
||||
).small.muted,
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -542,9 +854,9 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
rowHeight: 48,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -557,6 +869,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: Select<String>(
|
||||
itemBuilder: (context, item) {
|
||||
return Text("Filter by: $item");
|
||||
@@ -602,6 +915,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: Select<Object>(
|
||||
itemBuilder: (context, item) {
|
||||
|
||||
@@ -621,10 +935,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
return Text("Trip ${trip.tripNumber} • Duty ${trip.dutyNumber}");
|
||||
|
||||
} else if (filterBy == "By Stop") {
|
||||
|
||||
ScheduledStop stop = item as ScheduledStop;
|
||||
|
||||
return Text(stop.name);
|
||||
return _stopDisplayLabel(item.toString());
|
||||
|
||||
}
|
||||
|
||||
@@ -691,7 +1002,7 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
..sort();
|
||||
items = stops.map((s) => SelectItemButton(
|
||||
value: s,
|
||||
child: Text(s)
|
||||
child: _stopDisplayLabel(s)
|
||||
)).toList();
|
||||
}
|
||||
|
||||
@@ -742,11 +1053,9 @@ class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
||||
LucideIcons.upload
|
||||
).iconSmall,
|
||||
child: Text(
|
||||
"Upload"
|
||||
_saving ? "Uploading..." : "Upload"
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
},
|
||||
onPressed: _saving ? null : () => unawaited(_saveToChannel()),
|
||||
)
|
||||
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user