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 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; 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> _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 client = context.read().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 = >[]; 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 _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 _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; 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 _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: 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( 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( 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 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)), ], ); } }