import "dart:async"; import "dart:convert"; import "dart:io"; 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 createState() => _OperationsUploadPageState(); } class _OperationsUploadPageState extends State { bool _parsing = false; bool _saving = false; bool _enhancing = false; bool _enhanced = false; String? _error; List _parsedTrips = const []; String? _fileName; String? _parserType; String? _sourceMime; Future _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; 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"); debugPrintStack(stackTrace: stackTrace); if (!mounted) return; setState(() { _error = error.toString(); }); } finally { if (mounted) { setState(() { _parsing = false; }); } } } Future> _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 _sortedTrips() { final sorted = List.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 _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 sortedTrips = _sortedTrips(); final tripPayloads = >[]; for (var i = 0; i < sortedTrips.length; i++) { final trip = sortedTrips[i]; final stationOrder = trip.stationOrder.isEmpty ? trip.stationTimes.keys.toList() : trip.stationOrder; final stops = >[]; for (var s = 0; s < stationOrder.length; s++) { final stopName = stationOrder[s].trim(); if (stopName.isEmpty) continue; final scheduled = (trip.stationTimes[stopName] ?? "").trim(); stops.add({ "stop_sequence": s + 1, "stop_name": stopName, "scheduled_time": scheduled.isEmpty ? null : scheduled, }); } 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; 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 _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 = {}; 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 _invokeAuthedFunctionRaw( String functionName, { required Uint8List body, Map 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().client; Future 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 _invokeAuthedFunction( String functionName, { Object? body, }) async { final client = context.read().client; var token = await _getFreshAccessToken(); if (token == null || token.isEmpty) { throw StateError("No valid access token available for edge function call."); } Future invokeOnce(String accessToken) { client.functions.setAuth(accessToken); return client.functions.invoke( functionName, body: body, headers: {"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 _getFreshAccessToken() async { final client = context.read().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 = "By Duty"; Map 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 _selectedDutyTripsForFilter() { if (filterBy != "By Duty") return const []; final selectedDuty = (filterValue["By Duty"] ?? "").toString(); if (selectedDuty.isEmpty) return const []; final trips = _parsedTrips .where((trip) => trip.dutyNumber == selectedDuty) .toList(growable: false); final sorted = List.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 _tripDiagramEntries(Trip trip) { final orderedStops = List.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: 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: [ 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, height: 50, child: Select( 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, height: 50, child: Select( 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") { return _stopDisplayLabel(item.toString()); } 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 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: _stopDisplayLabel(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( _saving ? "Uploading..." : "Upload" ), onPressed: _saving ? null : () => unawaited(_saveToChannel()), ) ], ), ), Gap(max(bottomPadding, 16)), ], ); } }