diff --git a/lib/pages/home/channels/operations_channel_view.dart b/lib/pages/home/channels/operations_channel_view.dart index 609507c..48f8230 100644 --- a/lib/pages/home/channels/operations_channel_view.dart +++ b/lib/pages/home/channels/operations_channel_view.dart @@ -1,42 +1,850 @@ +import "dart:async"; +import "dart:math"; + import "package:bus_running_record/models/channels/operations_channel.dart"; +import "package:bus_running_record/models/operations/stop.dart"; +import "package:bus_running_record/widgets/trip_diagram.dart"; +import "package:flutter/foundation.dart"; import "package:go_router/go_router.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; -class OperationsChannelView extends StatelessWidget { + +class OperationsChannelView extends StatefulWidget { const OperationsChannelView({required this.channel, super.key}); final OperationsChannel channel; + @override + State createState() => _OperationsChannelViewState(); +} + +class _OperationsChannelViewState extends State { + + bool _loadingMeta = true; + bool _loadingDetail = false; + + String? _scheduleId; + List _duties = const []; + List<({String tripNumber, String dutyNumber})> _tripMeta = const []; + List _stopNames = const []; + Map _aliases = const {}; + + String filterBy = "By Duty"; + Map filterValue = {}; + + List? _dutyTrips; + OperationsTripSnapshot? _tripSnapshot; + List<({OperationsTrip trip, String? time})>? _stopSchedule; + bool _stopHasMore = false; + bool _stopLoadingMore = false; + int _stopOffset = 0; + static const int _stopPageSize = 20; + List _stopRowKeys = []; + + final ScrollController _stopScrollController = ScrollController(); + + + @override + void initState() { + super.initState(); + _stopScrollController.addListener(_onStopScroll); + unawaited(_loadMeta()); + } + + @override + void dispose() { + _stopScrollController.dispose(); + super.dispose(); + } + + void _onStopScroll() { + if (!_stopHasMore || _stopLoadingMore || _loadingDetail) return; + final pos = _stopScrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 100) { + unawaited(_loadMoreStop()); + } + } + + Future _invoke(String functionName, Map body) async { + final client = widget.channel.client; + client.functions.setAuth( + client.auth.currentSession?.accessToken ?? "", + ); + return client.functions.invoke( + functionName, + body: body, + headers: { + "Authorization": "Bearer ${client.auth.currentSession?.accessToken ?? ""}", + }, + ); + } + + Future _loadMeta() async { + setState(() { + _loadingMeta = true; + }); + + try { + final response = await _invoke("operations-schedule-meta", { + "channel_id": widget.channel.id, + }); + + final data = response.data as Map?; + if (data == null) throw StateError("Empty response from meta function."); + + if (data["has_schedule"] != true) { + if (!mounted) return; + setState(() { + _scheduleId = null; + _loadingMeta = false; + }); + return; + } + + final scheduleId = (data["schedule_id"] ?? "").toString(); + final duties = List.from(data["duties"] as List? ?? []); + + final tripMetaRaw = data["trips"] as List? ?? []; + final tripMeta = tripMetaRaw.map((t) { + final m = t as Map; + return ( + tripNumber: (m["trip_number"] ?? "").toString(), + dutyNumber: (m["duty_number"] ?? "").toString(), + ); + }).toList(); + + final stopNames = List.from(data["stop_names"] as List? ?? []); + + final aliasesRaw = data["aliases"] as List? ?? []; + final aliases = {}; + for (final row in aliasesRaw) { + final m = row as Map; + final raw = (m["raw_stop_name"] ?? "").toString().trim(); + final alias = (m["alias_stop_name"] ?? "").toString().trim(); + final source = (m["source"] ?? "user").toString(); + if (raw.isEmpty || alias.isEmpty) continue; + aliases[Stop.normalizeName(raw)] = (alias: alias, source: source); + } + + final firstDuty = duties.isNotEmpty ? duties.first : null; + + if (!mounted) return; + setState(() { + _scheduleId = scheduleId; + _duties = duties; + _tripMeta = tripMeta; + _stopNames = stopNames; + _aliases = aliases; + _loadingMeta = false; + + if (firstDuty != null) { + filterBy = "By Duty"; + filterValue["By Duty"] = firstDuty; + } + }); + + if (firstDuty != null) { + unawaited(_loadDutyDetail(firstDuty)); + } + } catch (error, stackTrace) { + debugPrint("[OperationsChannelView] loadMeta failed: $error"); + debugPrintStack(stackTrace: stackTrace); + if (!mounted) return; + setState(() => _loadingMeta = false); + } + } + + Future _loadDutyDetail(String dutyNumber) async { + final scheduleId = _scheduleId; + if (scheduleId == null) return; + + setState(() { + _loadingDetail = true; + _dutyTrips = null; + }); + + try { + final response = await _invoke("operations-duty-detail", { + "channel_id": widget.channel.id, + "schedule_id": scheduleId, + "duty_number": dutyNumber, + }); + + final data = response.data as Map?; + final tripsRaw = data?["trips"] as List? ?? []; + + final snapshots = []; + for (final tripData in tripsRaw) { + final m = tripData as Map; + final trip = OperationsTrip( + id: (m["id"] ?? "").toString(), + scheduleId: scheduleId, + tripNumber: (m["trip_number"] ?? "").toString(), + dutyNumber: (m["duty_number"] ?? "").toString(), + busWorkNumber: (m["bus_work_number"] ?? "").toString(), + direction: (m["direction"] ?? "").toString(), + sortOrder: (m["sort_order"] as num?)?.toInt() ?? 0, + ); + + final stopsRaw = m["stops"] as List? ?? []; + final stops = stopsRaw.map((s) { + final sm = s as Map; + final name = (sm["stop_name"] ?? "").toString().trim(); + final aliasEntry = _aliases[Stop.normalizeName(name)]; + return OperationsScheduledStop( + id: (sm["id"] ?? "").toString(), + tripId: trip.id, + sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0, + name: name, + alias: aliasEntry?.alias, + aliasSource: aliasEntry?.source, + scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty + ? null + : sm["scheduled_time"].toString(), + ); + }).toList() + ..sort((a, b) => a.sequence.compareTo(b.sequence)); + + snapshots.add(OperationsTripSnapshot( + trip: trip, + stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(), + scheduledStops: stops, + )); + } + + snapshots.sort((a, b) { + final aTime = a.scheduledStops.firstOrNull?.scheduledTime ?? ""; + final bTime = b.scheduledStops.firstOrNull?.scheduledTime ?? ""; + return aTime.compareTo(bTime); + }); + + if (!mounted) return; + setState(() { + _dutyTrips = snapshots; + _loadingDetail = false; + }); + } catch (error, stackTrace) { + debugPrint("[OperationsChannelView] loadDutyDetail failed: $error"); + debugPrintStack(stackTrace: stackTrace); + if (!mounted) return; + setState(() => _loadingDetail = false); + } + } + + Future _loadTripDetail(String tripNumber) async { + final scheduleId = _scheduleId; + if (scheduleId == null) return; + + setState(() { + _loadingDetail = true; + _tripSnapshot = null; + }); + + try { + final response = await _invoke("operations-trip-detail", { + "channel_id": widget.channel.id, + "schedule_id": scheduleId, + "trip_number": tripNumber, + }); + + final data = response.data as Map?; + final tripData = data?["trip"] as Map?; + if (tripData == null) { + if (!mounted) return; + setState(() => _loadingDetail = false); + return; + } + + final trip = OperationsTrip( + id: (tripData["id"] ?? "").toString(), + scheduleId: scheduleId, + tripNumber: (tripData["trip_number"] ?? "").toString(), + dutyNumber: (tripData["duty_number"] ?? "").toString(), + busWorkNumber: (tripData["bus_work_number"] ?? "").toString(), + direction: (tripData["direction"] ?? "").toString(), + sortOrder: (tripData["sort_order"] as num?)?.toInt() ?? 0, + ); + + final stopsRaw = data?["stops"] as List? ?? []; + final stops = stopsRaw.map((s) { + final sm = s as Map; + final name = (sm["stop_name"] ?? "").toString().trim(); + final aliasEntry = _aliases[Stop.normalizeName(name)]; + return OperationsScheduledStop( + id: (sm["id"] ?? "").toString(), + tripId: trip.id, + sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0, + name: name, + alias: aliasEntry?.alias, + aliasSource: aliasEntry?.source, + scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty + ? null + : sm["scheduled_time"].toString(), + ); + }).toList(); + + if (!mounted) return; + setState(() { + _tripSnapshot = OperationsTripSnapshot( + trip: trip, + stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(), + scheduledStops: stops, + ); + _loadingDetail = false; + }); + } catch (error, stackTrace) { + debugPrint("[OperationsChannelView] loadTripDetail failed: $error"); + debugPrintStack(stackTrace: stackTrace); + if (!mounted) return; + setState(() => _loadingDetail = false); + } + } + + Future _loadStopDetail(String stopName) async { + final scheduleId = _scheduleId; + if (scheduleId == null) return; + + setState(() { + _loadingDetail = true; + _stopSchedule = null; + _stopOffset = 0; + _stopHasMore = false; + }); + + try { + final response = await _invoke("operations-stop-detail", { + "channel_id": widget.channel.id, + "schedule_id": scheduleId, + "stop_name": stopName, + "offset": 0, + "limit": _stopPageSize, + }); + + final data = response.data as Map?; + final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []); + final hasMore = data?["has_more"] == true; + + if (!mounted) return; + setState(() { + _stopSchedule = page; + _stopOffset = page.length; + _stopHasMore = hasMore; + _stopRowKeys = List.generate(page.length, (_) => GlobalKey()); + _loadingDetail = false; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToClosestTime(page)); + } catch (error, stackTrace) { + debugPrint("[OperationsChannelView] loadStopDetail failed: $error"); + debugPrintStack(stackTrace: stackTrace); + if (!mounted) return; + setState(() => _loadingDetail = false); + } + } + + Future _loadMoreStop() async { + final scheduleId = _scheduleId; + if (scheduleId == null || !_stopHasMore || _stopLoadingMore) return; + final stopName = (filterValue["By Stop"] ?? "").toString().trim(); + if (stopName.isEmpty) return; + + setState(() => _stopLoadingMore = true); + + try { + final response = await _invoke("operations-stop-detail", { + "channel_id": widget.channel.id, + "schedule_id": scheduleId, + "stop_name": stopName, + "offset": _stopOffset, + "limit": _stopPageSize, + }); + + final data = response.data as Map?; + final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []); + final hasMore = data?["has_more"] == true; + + if (!mounted) return; + setState(() { + _stopSchedule = [...?_stopSchedule, ...page]; + _stopOffset += page.length; + _stopHasMore = hasMore; + _stopLoadingMore = false; + }); + } catch (error, stackTrace) { + debugPrint("[OperationsChannelView] loadMoreStop failed: $error"); + debugPrintStack(stackTrace: stackTrace); + if (!mounted) return; + setState(() => _stopLoadingMore = false); + } + } + + void _scrollToClosestTime(List<({OperationsTrip trip, String? time})> schedule) { + if (!_stopScrollController.hasClients) return; + if (schedule.isEmpty) return; + + final now = DateTime.now(); + final nowMins = now.hour * 60 + now.minute; + + int bestIndex = 0; + int bestDiff = 9999; + + for (var i = 0; i < schedule.length; i++) { + final time = schedule[i].time; + if (time == null) continue; + final parts = time.split(":"); + if (parts.length < 2) continue; + final h = int.tryParse(parts[0]); + final m = int.tryParse(parts[1]); + if (h == null || m == null) continue; + final diff = ((h * 60 + m) - nowMins).abs(); + if (diff < bestDiff) { + bestDiff = diff; + bestIndex = i; + } + } + + // each row is roughly 61px tall (10 vertical padding top+bottom + ~41 content) + const rowHeight = 61.0; + final offset = (bestIndex * rowHeight).clamp( + 0.0, + _stopScrollController.position.maxScrollExtent, + ); + + _stopScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ); + } + + List<({OperationsTrip trip, String? time})> _parseStopSchedule( + String scheduleId, + List scheduleRaw, + ) { + return scheduleRaw.map((row) { + final m = row as Map; + final tripData = m["trip"] as Map; + final trip = OperationsTrip( + id: (tripData["id"] ?? "").toString(), + scheduleId: scheduleId, + tripNumber: (tripData["trip_number"] ?? "").toString(), + dutyNumber: (tripData["duty_number"] ?? "").toString(), + busWorkNumber: (tripData["bus_work_number"] ?? "").toString(), + direction: (tripData["direction"] ?? "").toString(), + sortOrder: 0, + ); + final time = (m["scheduled_time"] ?? "").toString().trim(); + return (trip: trip, time: time.isEmpty ? null : time); + }).toList(); + } + + String? _aliasForStop(String rawName) => + _aliases[Stop.normalizeName(rawName)]?.alias; + + Widget _stopLabel(String rawName) { + final alias = _aliasForStop(rawName); + if (alias == null || alias.isEmpty) return Text(rawName); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(alias).semiBold, + if (alias != rawName) + Text( + rawName, + style: const TextStyle(fontSize: 11, height: 1), + ).xSmall.muted.semiBold, + ], + ); + } + + List _diagramEntries(List stops) { + return stops.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); + } + @override Widget build(BuildContext context) { + double bottomPadding = MediaQuery.of(context).padding.bottom; + bool isMobile = defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; - final isScheduleUploaded = channel.id.isEmpty; + if (_loadingMeta) { + return const Center(child: CircularProgressIndicator()); + } - if (!isScheduleUploaded) { + if (_scheduleId == null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - "No schedule uploaded yet.", - ).h4, - + Text("No schedule uploaded yet.").h4, Gap(8), - Button.secondary( - child: Text( - "Upload Schedule", + onPressed: () => context.go( + "/channel/${widget.channel.organizationId}/${widget.channel.id}/upload", ), - onPressed: () { - context.go( - "/channel/${channel.organizationId}/${channel.id}/upload", - ); - }, - ) + child: const Text("Upload Schedule"), + ), ], - ) + ), ); } - return const SizedBox.expand(); + + return Column( + children: [ + + Expanded( + child: Builder( + builder: (context) { + + if (_loadingDetail) { + return const Center(child: CircularProgressIndicator()); + } + + // By Stop + if (filterBy == "By Stop") { + final selectedStop = (filterValue["By Stop"] ?? "").toString().trim(); + if (selectedStop.isEmpty) { + return Center(child: Text("Select a stop to preview its schedule.").small.muted); + } + + final schedules = _stopSchedule ?? const []; + return Column( + children: [ + Gap(8), + Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(LucideIcons.pin), + Gap(8), + _stopLabel(selectedStop), + ], + ), + ), + Divider(), + Gap(2), + Divider(), + Expanded( + child: SingleChildScrollView( + controller: _stopScrollController, + 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: [ + Row( + children: [ + Text("${row.trip.dutyNumber} • ").extraBold, + Text( + "Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}", + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ).muted, + ], + ), + Row( + children: [ + Text(row.trip.direction).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(row.time ?? "--:--").extraBold.mono, + Gap(2), + Icon(LucideIcons.chevronRight), + Gap(2), + ], + ), + ), + ), + + if (_stopLoadingMore) + const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ], + ), + ), + ), + ], + ); + } + + // By Duty + if (filterBy == "By Duty") { + final selectedDuty = (filterValue["By Duty"] ?? "").toString(); + if (selectedDuty.isEmpty) { + return Center(child: Text("Select a duty to preview.").small.muted); + } + + final snapshots = _dutyTrips ?? const []; + if (snapshots.isEmpty) { + return Center(child: Text("No trips found for this duty.").small.muted); + } + + 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 $selectedDuty".toUpperCase()).extraBold.textSmall, + ], + ), + ), + Divider(), + Gap(2), + Divider(), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < snapshots.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 ${snapshots[i].trip.tripNumber} • Duty ${snapshots[i].trip.dutyNumber}", + ).semiBold.textSmall, + ], + ), + ), + Divider(), + TripDiagram( + lineColor: Colors.red, + entries: _diagramEntries(snapshots[i].scheduledStops), + leftOffset: 12, + rowHeight: 48, + ), + ], + ], + ), + ), + ), + ], + ); + } + + // By Trip + if (filterBy == "By Trip") { + final selectedTripNumber = (filterValue["By Trip"] ?? "").toString(); + if (selectedTripNumber.isEmpty) { + return Center(child: Text("Select a trip to preview.").small.muted); + } + + final snapshot = _tripSnapshot; + if (snapshot == null) { + return Center(child: Text("No data for this trip.").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 ${snapshot.trip.tripNumber} • Duty ${snapshot.trip.dutyNumber}", + ).semiBold.textSmall, + ], + ), + ), + Divider(), + TripDiagram( + lineColor: Colors.red, + entries: _diagramEntries(snapshot.scheduledStops), + leftOffset: 12, + rowHeight: 48, + ), + ], + ), + ); + } + + return Center( + child: Text("Select a filter to preview the schedule.").small.muted, + ); + }, + ), + ), + + Divider(), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + + SizedBox( + width: double.infinity, + height: 50, + child: Select( + itemBuilder: (context, item) => Text("Filter by: $item"), + popupConstraints: BoxConstraints( + maxHeight: 300, + maxWidth: !isMobile ? 200 : double.infinity, + ), + onChanged: (value) { + setState(() { + filterBy = value ?? filterBy; + }); + }, + 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"); + + if (filterBy == "By Trip") { + final tripNumber = item.toString(); + final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull; + if (match == null) return Text("Trip $tripNumber"); + return Text("Trip ${match.tripNumber} • Duty ${match.dutyNumber}"); + } + + if (filterBy == "By Stop") return _stopLabel(item.toString()); + + return Text("Undefined"); + }, + popupConstraints: BoxConstraints( + maxHeight: 300, + maxWidth: !isMobile ? 200 : double.infinity, + ), + onChanged: (value) { + if (value == null) return; + setState(() { + filterValue[filterBy] = value; + _dutyTrips = null; + _tripSnapshot = null; + _stopSchedule = null; + }); + if (filterBy == "By Duty") { + unawaited(_loadDutyDetail(value.toString())); + } else if (filterBy == "By Trip") { + unawaited(_loadTripDetail(value.toString())); + } else if (filterBy == "By Stop") { + unawaited(_loadStopDetail(value.toString())); + } + }, + value: filterValue[filterBy], + popup: SelectPopup.builder( + searchPlaceholder: Text("Search ${filterBy.toLowerCase()}"), + builder: (context, searchQuery) { + List items = []; + + if (filterBy == "By Duty") { + items = _duties.map((d) => SelectItemButton( + value: d, + child: Text(d), + )).toList(); + } else if (filterBy == "By Trip") { + final trips = List.from(_tripMeta); + trips.sort((a, b) { + 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); + }); + items = trips.map((t) => SelectItemButton( + value: t.tripNumber, + child: Text("Trip ${t.tripNumber}"), + )).toList(); + } else if (filterBy == "By Stop") { + items = _stopNames.map((s) => SelectItemButton( + value: s, + child: _stopLabel(s), + )).toList(); + } + + return SelectItemList(children: items); + }, + ), + ), + ), + + ], + ), + ), + + Gap(max(bottomPadding, 16)), + + ], + ); } } diff --git a/lib/pages/home/widgets/home_left_sidebar.dart b/lib/pages/home/widgets/home_left_sidebar.dart index 9a05e49..d0725cc 100644 --- a/lib/pages/home/widgets/home_left_sidebar.dart +++ b/lib/pages/home/widgets/home_left_sidebar.dart @@ -26,71 +26,71 @@ class HomeLeftSidebar extends StatelessWidget { final displayName = user?.email ?? "Signed in user"; final initials = _buildInitials(displayName); - return GestureDetector( - behavior: HitTestBehavior.opaque, - child: Row( - children: [ - SizedBox( - width: 70, - child: Column( - children: [ - Expanded( - child: _ServerRail( - organizations: organizations, - selectedOrganizationId: collab.selectedOrganizationId, + double topPadding = MediaQuery.of(context).padding.top; + double bottomPadding = MediaQuery.of(context).padding.bottom; + + return Row( + children: [ + SizedBox( + width: 70, + child: Column( + children: [ + Expanded( + child: _ServerRail( + organizations: organizations, + selectedOrganizationId: collab.selectedOrganizationId, + ), + ), + Container( + padding: const EdgeInsets.all(8.0), + width: 100, + child: AspectRatio( + aspectRatio: 1, + child: IconButton.outline( + onPressed: () { + unawaited(showCreateOrganizationDialog(context)); + }, + shape: ButtonShape.circle, + size: ButtonSize.normal, + icon: const Icon(LucideIcons.plus), ), ), - Container( - padding: const EdgeInsets.all(8.0), - width: 100, - child: AspectRatio( - aspectRatio: 1, - child: IconButton.outline( - onPressed: () { - unawaited(showCreateOrganizationDialog(context)); - }, - shape: ButtonShape.circle, - size: ButtonSize.normal, - icon: const Icon(LucideIcons.plus), - ), + ), + const Divider(), + RotatedBox( + quarterTurns: 3, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "ROADBOUND", + style: const TextStyle(height: 1), + ).extraBold.x3Large, + Text( + "by IMBENJI.NET LTD", + style: const TextStyle(height: 1), + ).small.muted, + ], ), ), - const Divider(), - RotatedBox( - quarterTurns: 3, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "ROADBOUND", - style: const TextStyle(height: 1), - ).extraBold.x3Large, - Text( - "by IMBENJI.NET LTD", - style: const TextStyle(height: 1), - ).small.muted, - ], - ), - ), - ), - ], - ), + ), + Gap(bottomPadding) + ], ), - const VerticalDivider(), - Container( + ), + const VerticalDivider(), + Expanded( + child: Container( color: Theme.of(context).colorScheme.background, - width: 300, - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width - 50, - ), child: IntrinsicWidth( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Gap(topPadding), Padding( padding: const EdgeInsets.all(8), child: SizedBox( @@ -178,8 +178,8 @@ class HomeLeftSidebar extends StatelessWidget { ), ), ), - ], - ), + ), + ], ); } } @@ -301,62 +301,68 @@ class _ServerRail extends StatelessWidget { return const SizedBox.shrink(); } + double topPadding = MediaQuery.of(context).padding.top; + return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( - children: organizations.map((organization) { - final isSelected = selectedOrganizationId == organization.id; - final fallbackInitial = _fallbackInitial(organization.name); - final buttonStyle = _organizationButtonStyle(isSelected); + children: [ + Gap(topPadding), - return Padding( - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: HoverCard( - hoverBuilder: (context) { - return SurfaceCard( - child: Basic(title: Text(organization.name).medium), - ); - }, - anchorAlignment: Alignment.centerLeft, - popoverAlignment: Alignment.centerRight, - popoverOffset: const Offset(-4, 0), - child: ContextMenu( - items: [ - MenuLabel(child: Text(organization.name)), - const MenuDivider(), - MenuButton( - leading: const Icon(LucideIcons.settings2).iconSmall, - onPressed: (_) { - _openOrganizationSettings(context, organization.id); - }, - child: const Text("Server Settings"), - ), - ], - child: Stack( - children: [ - _buildOrganizationAvatar( - organization: organization, - fallbackInitial: fallbackInitial, - ), - SizedBox( - width: 54, - height: 54, - child: Button( - style: buttonStyle, - onPressed: () { - unawaited( - _openOrganization(context, organization.id), - ); - }, - child: Container(), - ), + ...organizations.map((organization) { + final isSelected = selectedOrganizationId == organization.id; + final fallbackInitial = _fallbackInitial(organization.name); + final buttonStyle = _organizationButtonStyle(isSelected); + + return Padding( + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: HoverCard( + hoverBuilder: (context) { + return SurfaceCard( + child: Basic(title: Text(organization.name).medium), + ); + }, + anchorAlignment: Alignment.centerLeft, + popoverAlignment: Alignment.centerRight, + popoverOffset: const Offset(-4, 0), + child: ContextMenu( + items: [ + MenuLabel(child: Text(organization.name)), + const MenuDivider(), + MenuButton( + leading: const Icon(LucideIcons.settings2).iconSmall, + onPressed: (_) { + _openOrganizationSettings(context, organization.id); + }, + child: const Text("Server Settings"), ), ], + child: Stack( + children: [ + _buildOrganizationAvatar( + organization: organization, + fallbackInitial: fallbackInitial, + ), + SizedBox( + width: 54, + height: 54, + child: Button( + style: buttonStyle, + onPressed: () { + unawaited( + _openOrganization(context, organization.id), + ); + }, + child: Container(), + ), + ), + ], + ), ), ), - ), - ); - }).toList(), + ); + }) + ], ), ); } diff --git a/lib/pages/home/widgets/swiper.dart b/lib/pages/home/widgets/swiper.dart index 1f8acbf..214533a 100644 --- a/lib/pages/home/widgets/swiper.dart +++ b/lib/pages/home/widgets/swiper.dart @@ -1,126 +1,69 @@ -import "dart:math" as math; -import "package:flutter/material.dart"; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class SidebarSwiper extends StatefulWidget { const SidebarSwiper({ required this.sidebar, required this.child, - this.maxSidebarWidth = 360, - this.sidebarWidthFactor = 0.92, - this.edgeDragWidth = 44, - this.animationDuration = const Duration(milliseconds: 220), super.key, }); final Widget sidebar; final Widget child; - final double maxSidebarWidth; - final double sidebarWidthFactor; - final double edgeDragWidth; - final Duration animationDuration; @override State createState() => _SidebarSwiperState(); } class _SidebarSwiperState extends State { - static const double _closedExtraOffset = 12; + final _controller = PageController(initialPage: 1); - double _progress = 0; // 0 = closed, 1 = fully open - bool _isDragging = false; - bool _canDrag = false; - double _dragStartGlobalX = 0; - double _dragStartProgress = 0; - - void _setOpen(bool value) { - setState(() { - _isDragging = false; - _progress = value ? 1 : 0; - }); - } - - void _handleDragStart(DragStartDetails details, double sidebarWidth) { - final isOpen = _progress > 0.001; - final fromEdge = details.globalPosition.dx <= widget.edgeDragWidth; - final fromSidebarZone = details.globalPosition.dx <= sidebarWidth; - _canDrag = fromEdge || (isOpen && fromSidebarZone); - if (!_canDrag) return; - setState(() { - _isDragging = true; - _dragStartGlobalX = details.globalPosition.dx; - _dragStartProgress = _progress; - }); - } - - void _handleDragUpdate(DragUpdateDetails details, double sidebarWidth) { - if (!_canDrag || !_isDragging) return; - if (sidebarWidth <= 0) return; - final movedX = details.globalPosition.dx - _dragStartGlobalX; - setState(() { - _progress = (_dragStartProgress + (movedX / sidebarWidth)).clamp(0, 1); - }); - } - - void _handleDragEnd(DragEndDetails details) { - if (!_canDrag) return; - _canDrag = false; - final velocity = details.primaryVelocity ?? 0; - final shouldOpen = velocity > 250 - ? true - : (velocity < -250 ? false : _progress >= 0.35); - _setOpen(shouldOpen); + @override + void dispose() { + _controller.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final sidebarWidth = math.min( - widget.maxSidebarWidth, - screenWidth * widget.sidebarWidthFactor, - ); - final leftOffset = - -(sidebarWidth + _closedExtraOffset) + - ((sidebarWidth + _closedExtraOffset) * _progress); - final showScrim = _progress > 0; + return Scaffold( + child: PageView( + controller: _controller, + physics: const ClampingScrollPhysics(), + children: [ + // page 0 - sidebar + Row( + children: [ + Expanded(child: _KeepAlive(child: Scaffold(child: widget.sidebar))), + const VerticalDivider(width: 1), + ], + ), - return Stack( - children: [ - widget.child, - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (details) => - _handleDragStart(details, sidebarWidth), - onHorizontalDragUpdate: (details) => - _handleDragUpdate(details, sidebarWidth), - onHorizontalDragEnd: _handleDragEnd, - child: const SizedBox.expand(), - ), - ), - if (showScrim) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _setOpen(false), - child: Container( - color: Colors.black.withValues(alpha: 0.45 * _progress), - ), - ), - ), - AnimatedPositioned( - duration: _isDragging ? Duration.zero : widget.animationDuration, - curve: Curves.easeOutCubic, - left: leftOffset, - top: 0, - bottom: 0, - width: sidebarWidth, - child: Material( - color: Theme.of(context).scaffoldBackgroundColor, - child: widget.sidebar, - ), - ), - ], + // page 1 - main content + _KeepAlive(child: widget.child), + ], + ), ); } } + +class _KeepAlive extends StatefulWidget { + const _KeepAlive({required this.child}); + final Widget child; + + @override + State<_KeepAlive> createState() => _KeepAliveState(); +} + +class _KeepAliveState extends State<_KeepAlive> + with AutomaticKeepAliveClientMixin { + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return SizedBox.expand(child: widget.child); + } +} diff --git a/lib/pages/operations_upload/page.dart b/lib/pages/operations_upload/page.dart index 3d6d7b7..1c26996 100644 --- a/lib/pages/operations_upload/page.dart +++ b/lib/pages/operations_upload/page.dart @@ -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 { 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 { _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(); + + final tripPayloads = >[]; 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; + + 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(); - 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 { } } + 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, @@ -458,7 +482,7 @@ class _OperationsUploadPageState extends State { ); } - String? filterBy; + String? filterBy = "By Duty"; Map filterValue = {}; Trip? _selectedTripForFilter() { @@ -473,6 +497,99 @@ class _OperationsUploadPageState extends State { 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)); @@ -497,16 +614,211 @@ class _OperationsUploadPageState extends State { 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 { rowHeight: 48, ), ], - ); - }, - ), + ), + ); + }, ), ), @@ -557,6 +869,7 @@ class _OperationsUploadPageState extends State { SizedBox( width: double.infinity, + height: 50, child: Select( itemBuilder: (context, item) { return Text("Filter by: $item"); @@ -602,6 +915,7 @@ class _OperationsUploadPageState extends State { SizedBox( width: double.infinity, + height: 50, child: Select( itemBuilder: (context, item) { @@ -621,10 +935,7 @@ class _OperationsUploadPageState extends State { 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 { ..sort(); items = stops.map((s) => SelectItemButton( value: s, - child: Text(s) + child: _stopDisplayLabel(s) )).toList(); } @@ -742,11 +1053,9 @@ class _OperationsUploadPageState extends State { LucideIcons.upload ).iconSmall, child: Text( - "Upload" + _saving ? "Uploading..." : "Upload" ), - onPressed: () { - - }, + onPressed: _saving ? null : () => unawaited(_saveToChannel()), ) ], diff --git a/lib/pages/org_settings/page.dart b/lib/pages/org_settings/page.dart index 9d8bf71..52daef4 100644 --- a/lib/pages/org_settings/page.dart +++ b/lib/pages/org_settings/page.dart @@ -67,15 +67,18 @@ class _OrganizationSettingsPageState extends State { } final organizationId = organization?.id; + double topPadding = MediaQuery.of(context).padding.top; + return Scaffold( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 820), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820), + child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Gap(topPadding), Button.text( leading: const Icon(LucideIcons.arrowLeft), onPressed: () => context.go("/"), diff --git a/lib/parsers/stagecoach_schedule_parser.dart b/lib/parsers/stagecoach_schedule_parser.dart index dfd92e0..00e0d88 100644 --- a/lib/parsers/stagecoach_schedule_parser.dart +++ b/lib/parsers/stagecoach_schedule_parser.dart @@ -351,10 +351,12 @@ class StagecoachScheduleParser implements ScheduleParser { ? stations.reversed.toList() : List.from(stations); + final normalizedDuty = dutyId.startsWith("2/") ? dutyId.substring(2) : dutyId; + return Trip( scheduledTime: scheduledTime, tripNumber: runNumber, - dutyNumber: dutyId, + dutyNumber: normalizedDuty, busWorkNumber: busWorkingNo, tripType: tripType, isFinishing: segment.contains("BUS FIN"), diff --git a/supabase/config.toml b/supabase/config.toml index a56829c..2267fe1 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -33,3 +33,18 @@ verify_jwt = false [functions.operations-stop-alias-enhance] verify_jwt = false + +[functions.operations-schedule-upload] +verify_jwt = false + +[functions.operations-schedule-meta] +verify_jwt = false + +[functions.operations-duty-detail] +verify_jwt = false + +[functions.operations-trip-detail] +verify_jwt = false + +[functions.operations-stop-detail] +verify_jwt = false diff --git a/supabase/functions/operations-stop-alias-enhance/index.ts b/supabase/functions/operations-stop-alias-enhance/index.ts index ed87afe..537f378 100644 --- a/supabase/functions/operations-stop-alias-enhance/index.ts +++ b/supabase/functions/operations-stop-alias-enhance/index.ts @@ -43,6 +43,22 @@ function extractCode(rawStopName: string): string { return normalized.slice(0, 8); } +function hasStandaloneTerminusToken(rawStopName: string): boolean { + const tokens = rawStopName + .trim() + .split(/\s+/) + .map((token) => token.toUpperCase()) + .filter((token) => token.length > 0); + if (tokens.length === 0) return false; + return tokens[tokens.length - 1] === "T"; +} + +function applyStandRule(rawStopName: string, estimatedName: string): string { + if (!hasStandaloneTerminusToken(rawStopName)) return estimatedName; + if (estimatedName.toLowerCase().endsWith(" stand")) return estimatedName; + return `${estimatedName} Stand`; +} + Deno.serve(async (req) => { const preflight = handleOptions(req); if (preflight) return preflight; @@ -143,13 +159,32 @@ Deno.serve(async (req) => { const estimated = codeToEstimated.get(code); if (!estimated) continue; for (const raw of rawStops) { + const aliasWithStandRule = applyStandRule(raw, estimated); aliases.push({ raw_stop_name: raw, - alias_stop_name: estimated, + alias_stop_name: aliasWithStandRule, source: "ai", }); } } + if (aliases.length > 0) { + const upsertRows = aliases.map((a) => ({ + channel_id: channelId, + raw_stop_name: a.raw_stop_name, + alias_stop_name: a.alias_stop_name, + source: a.source, + created_by: user.id, + })); + + const { error: upsertError } = await client + .from("operations_stop_aliases") + .upsert(upsertRows, { onConflict: "channel_id,raw_stop_name_normalized", ignoreDuplicates: false }); + + if (upsertError) { + console.error("[operations-stop-alias-enhance] failed to persist aliases:", upsertError.message); + } + } + return json({ aliases }); });