Roadbound-BRR/lib/pages/operations_upload/page.dart

761 lines
23 KiB
Dart

import "dart:async";
import "dart:math";
import "package:bus_running_record/models/operations/scheduled_stop.dart";
import "package:bus_running_record/models/operations/trip.dart";
import "package:bus_running_record/parsers/arriva_schedule_parser.dart";
import "package:bus_running_record/parsers/stagecoach_schedule_parser.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:bus_running_record/widgets/trip_diagram.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class OperationsUploadPage extends StatefulWidget {
const OperationsUploadPage({
required this.organizationId,
required this.channelId,
super.key,
});
final String organizationId;
final String channelId;
static final GoRoute route = GoRoute(
path: "/channel/:orgId/:channelId/upload",
builder: (context, state) => OperationsUploadPage(
organizationId: state.pathParameters["orgId"] ?? "",
channelId: state.pathParameters["channelId"] ?? "",
),
);
@override
State<OperationsUploadPage> createState() => _OperationsUploadPageState();
}
class _OperationsUploadPageState extends State<OperationsUploadPage> {
bool _parsing = false;
bool _saving = false;
bool _enhancing = false;
bool _enhanced = false;
String? _error;
List<Trip> _parsedTrips = const [];
String? _fileName;
String? _parserType;
String? _sourceMime;
Future<void> _pickAndParse() async {
if (_parsing) return;
setState(() {
_parsing = true;
_error = null;
});
try {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
withData: true,
type: FileType.custom,
allowedExtensions: const ["docx", "pdf"],
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.bytes == null) {
throw StateError("Could not read schedule file bytes.");
}
final ext = (file.extension ?? "").toLowerCase();
final parserType = ext == "pdf" ? "stagecoach" : "arriva";
final sourceMime = ext == "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
final trips = await _parseTrips(file.bytes!, ext);
if (trips.isEmpty) {
throw StateError("No trips parsed from schedule.");
}
if (!mounted) return;
setState(() {
_parsedTrips = trips;
_fileName = file.name;
_parserType = parserType;
_sourceMime = sourceMime;
_enhanced = false;
});
} catch (error, stackTrace) {
debugPrint("[OperationsUploadPage] parse failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_parsing = false;
});
}
}
}
Future<List<Trip>> _parseTrips(Uint8List bytes, String extension) async {
if (extension == "pdf") return StagecoachScheduleParser().parseBytes(bytes);
if (extension == "docx") return ArrivaScheduleParser().parseBytes(bytes);
throw UnsupportedError("Unsupported schedule extension: $extension");
}
List<Trip> _sortedTrips() {
final sorted = List<Trip>.from(_parsedTrips);
sorted.sort((a, b) {
final aNum = int.tryParse(a.tripNumber);
final bNum = int.tryParse(b.tripNumber);
if (aNum != null && bNum != null) {
final byNumber = aNum.compareTo(bNum);
if (byNumber != 0) return byNumber;
} else {
final byText = a.tripNumber.compareTo(b.tripNumber);
if (byText != 0) return byText;
}
return a.scheduledTime.compareTo(b.scheduledTime);
});
return sorted;
}
Future<void> _saveToChannel() async {
if (_saving || _parsedTrips.isEmpty) return;
final parserType = _parserType;
final fileName = _fileName;
final sourceMime = _sourceMime;
if (parserType == null || fileName == null || sourceMime == null) return;
setState(() {
_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();
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;
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,
"stop_sequence": s + 1,
"stop_name": stopName,
"scheduled_time": scheduled.isEmpty ? null : scheduled,
});
}
if (stopRows.isNotEmpty) {
await client.from("operations_trip_stops").insert(stopRows);
}
}
if (!mounted) return;
context.go("/channel/${widget.organizationId}/${widget.channelId}");
} catch (error, stackTrace) {
debugPrint("[OperationsUploadPage] save failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
Future<void> _enhanceStops() async {
if (_enhancing || _parsedTrips.isEmpty) return;
setState(() {
_enhancing = true;
_error = null;
});
try {
final stopNames = _parsedTrips
.expand((trip) => trip.scheduledStops.map((stop) => stop.name.trim()))
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (stopNames.isEmpty) {
if (!mounted) return;
setState(() {
_enhancing = false;
});
return;
}
final response = await _invokeAuthedFunction(
"operations-stop-alias-enhance",
body: {
"channel_id": widget.channelId,
"stop_names": stopNames,
},
);
final data = response.data;
if (data is! Map) {
throw StateError("Enhance function returned unexpected response.");
}
final aliasesRaw = data["aliases"];
if (aliasesRaw is! List) {
throw StateError("Enhance function returned invalid aliases.");
}
final aliasesByRawStopName = <String, String>{};
for (final row in aliasesRaw) {
if (row is! Map) continue;
final rawStopName = (row["raw_stop_name"] ?? "").toString().trim();
final aliasStopName = (row["alias_stop_name"] ?? "").toString().trim();
if (rawStopName.isEmpty || aliasStopName.isEmpty) continue;
aliasesByRawStopName[rawStopName] = aliasStopName;
}
final enhancedTrips = _parsedTrips
.map((trip) => trip.withStopAliases(aliasesByRawStopName))
.toList(growable: false);
if (!mounted) return;
setState(() {
_parsedTrips = enhancedTrips;
_enhanced = true;
});
} catch (error, stackTrace) {
debugPrint("[OperationsUploadPage] enhance failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_enhancing = false;
});
}
}
}
Future<dynamic> _invokeAuthedFunction(
String functionName, {
Object? body,
}) async {
final client = context.read<SupabaseProvider>().client;
var token = await _getFreshAccessToken();
if (token == null || token.isEmpty) {
throw StateError("No valid access token available for edge function call.");
}
Future<dynamic> invokeOnce(String accessToken) {
client.functions.setAuth(accessToken);
return client.functions.invoke(
functionName,
body: body,
headers: <String, String>{"Authorization": "Bearer $accessToken"},
);
}
try {
return await invokeOnce(token);
} catch (error, stackTrace) {
debugPrint(
"[OperationsUploadPage] invokeAuthedFunction/$functionName initial attempt 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<String?> _getFreshAccessToken() async {
final client = context.read<SupabaseProvider>().client;
var session = client.auth.currentSession;
final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresAt = session?.expiresAt;
final shouldRefresh =
session != null && expiresAt != null && expiresAt <= nowUnix + 30;
if (shouldRefresh) {
try {
final refreshed = await client.auth.refreshSession();
session = refreshed.session ?? client.auth.currentSession;
} catch (error, stackTrace) {
debugPrint(
"[OperationsUploadPage] getFreshAccessToken/refreshSession failed: $error",
);
debugPrintStack(stackTrace: stackTrace);
session = client.auth.currentSession;
}
}
return session?.accessToken;
}
bool _isUnauthorizedFunctionError(Object error) {
final text = error.toString();
return text.contains("status: 401") || text.contains("code: 401");
}
@override
Widget build(BuildContext context) {
double topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(topPadding),
SizedBox(
height: 40,
child: Column(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
IconButton.ghost(
density: ButtonDensity.iconDense,
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go(
"/channel/${widget.organizationId}/${widget.channelId}",
),
),
Gap(8),
Text(
"Operations Schedule Upload",
).textSmall,
],
),
),
),
),
const Divider(),
],
),
),
Expanded(
child: _parsedTrips.isEmpty
? _buildBeforeUpload(context)
: _buildParsedPreview(context),
)
],
),
);
}
Widget _buildBeforeUpload(BuildContext context){
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Upload Operations Schedule").h4,
const Gap(10),
Button.primary(
onPressed: _parsing ? null : () => unawaited(_pickAndParse()),
child: _parsing
? const Text("Parsing...")
: const Text("Choose File"),
),
if (_error != null) ...[
const Gap(8),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).small,
],
],
),
),
);
}
String? filterBy;
Map<String, Object?> filterValue = {};
Trip? _selectedTripForFilter() {
if (filterBy != "By Trip") return null;
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
if (selectedTripNumber.isEmpty) return null;
for (final trip in _parsedTrips) {
if (trip.tripNumber == selectedTripNumber) {
return trip;
}
}
return null;
}
List<TripDiagramEntry> _tripDiagramEntries(Trip trip) {
final orderedStops = List<ScheduledStop>.from(trip.scheduledStops)
..sort((a, b) => a.sequence.compareTo(b.sequence));
return orderedStops
.map((stop) => TripDiagramEntry(
label: stop.displayName,
labelIcon: stop.aliasSource == "ai" ? LucideIcons.sparkles : null,
subtitle: stop.displayName == stop.name ? null : stop.name,
time: stop.scheduledTime
))
.toList(growable: false);
}
Widget _buildParsedPreview(BuildContext context) {
double bottomPadding = MediaQuery.of(context).padding.bottom;
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android;
return Column(
children: [
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;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(8),
Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
LucideIcons.bus,
),
Gap(8),
Text(
"Trip ${selectedTrip.tripNumber} • Duty ${selectedTrip.dutyNumber}",
).semiBold.textSmall,
],
),
),
Divider(),
TripDiagram(
lineColor: Colors.red,
entries: _tripDiagramEntries(selectedTrip),
leftOffset: 12,
rowHeight: 48,
),
],
);
},
),
),
),
Divider(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: Select<String>(
itemBuilder: (context, item) {
return Text("Filter by: $item");
},
popupConstraints: BoxConstraints(
maxHeight: 300,
maxWidth: !isMobile ? 200 : double.infinity,
),
onChanged: (value) {
setState(() {
filterBy = value;
});
},
value: filterBy,
popup: SelectPopup(
items: SelectItemList(
children: [
SelectItemButton(
value: "By Duty",
child: Text(
"By Duty"
)
),
SelectItemButton(
value: "By Trip",
child: Text(
"By Trip"
)
),
SelectItemButton(
value: "By Stop",
child: Text(
"By Stop"
)
)
]
),
),
),
),
Gap(16),
SizedBox(
width: double.infinity,
child: Select<Object>(
itemBuilder: (context, item) {
if (filterBy == "By Duty") {
return Text(
"Duty $item"
);
} else if (filterBy == "By Trip") {
final tripNumber = item.toString();
final matchingTrip = _parsedTrips.where(
(trip) => trip.tripNumber == tripNumber,
);
if (matchingTrip.isEmpty) return Text("Trip $tripNumber");
final trip = matchingTrip.first;
return Text("Trip ${trip.tripNumber} • Duty ${trip.dutyNumber}");
} else if (filterBy == "By Stop") {
ScheduledStop stop = item as ScheduledStop;
return Text(stop.name);
}
return Text("Undefined");
},
popupConstraints: BoxConstraints(
maxHeight: 300,
maxWidth: !isMobile ? 200 : double.infinity,
),
onChanged: (value) {
setState(() {
if (filterBy == null) return;
filterValue[filterBy!] = value;
});
},
value: filterValue[filterBy],
popup: SelectPopup.builder(
searchPlaceholder: Text(
"Search ${filterBy?.toLowerCase()}"
),
builder: (context, searchQuery) {
List<SelectItemButton> items = [];
if (filterBy == "By Duty") {
final duties = _parsedTrips
.map((t) => t.dutyNumber)
.toSet()
.toList()
..sort();
items = duties.map((d) => SelectItemButton(
value: d,
child: Text(d)
)).toList();
} else if (filterBy == "By Trip") {
final trips = _parsedTrips
.map((t) => t.tripNumber)
.toSet()
.toList()
..sort();
// Sort trips by number if possible, otherwise by text
trips.sort((a, b) {
final aNum = int.tryParse(a);
final bNum = int.tryParse(b);
if (aNum != null && bNum != null) {
return aNum.compareTo(bNum);
}
return a.compareTo(b);
});
items = trips.map((t) => SelectItemButton(
value: t,
child: Text("Trip $t")
)).toList();
} else if (filterBy == "By Stop") {
final stops = _parsedTrips
.expand((t) => t.stationTimes.keys)
.toSet()
.toList()
..sort();
items = stops.map((s) => SelectItemButton(
value: s,
child: Text(s)
)).toList();
}
return SelectItemList(
children: items,
);
},
),
),
)
],
)
),
Divider(),
Gap(8),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Row(
children: [
Button.secondary(
trailing: Icon(
LucideIcons.sparkle
).iconSmall,
child: Text(
_enhanced
? "Enhanced"
: _enhancing
? "Enhancing..."
: "Enhance"
),
onPressed: _enhancing || _enhanced
? null
: () => unawaited(_enhanceStops()),
),
Spacer(),
Button.secondary(
trailing: Icon(
LucideIcons.upload
).iconSmall,
child: Text(
"Upload"
),
onPressed: () {
},
)
],
),
),
Gap(max(bottomPadding, 16)),
],
);
}
}