Add bus detail functionality and update organization selection UI

This commit is contained in:
ImBenji 2026-03-29 19:12:49 +01:00
parent 55cd970173
commit 3dfea45afb
10 changed files with 425 additions and 97 deletions

View file

@ -10,3 +10,10 @@ const String kSupabaseEndpoint = "https://fbgvisimvgeksfxpemuk.supabase.co";
/// Leave empty to disable Supabase initialization.
const String kSupabasePublishableKey =
"sb_publishable_tqhag58YEhNrIHy264JsKg_T1VCIZib";
/*
* APP
*/
/// The app's hostname.
const String kAppHostname = "road.imbenji.net";

View file

@ -25,6 +25,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
String? _scheduleId;
List<String> _duties = const [];
List<String> _busWorkNumbers = const [];
List<({String tripNumber, String dutyNumber})> _tripMeta = const [];
List<String> _stopNames = const [];
Map<String, ({String alias, String source})> _aliases = const {};
@ -33,6 +34,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
Map<String, Object?> filterValue = {};
List<OperationsTripSnapshot>? _dutyTrips;
List<OperationsTripSnapshot>? _busTrips;
OperationsTripSnapshot? _tripSnapshot;
List<({OperationsTrip trip, String? time})>? _stopSchedule;
bool _stopHasMore = false;
@ -42,6 +44,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
List<GlobalKey> _stopRowKeys = [];
final ScrollController _stopScrollController = ScrollController();
final GlobalKey _stopScrollViewKey = GlobalKey();
@override
@ -103,6 +106,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
final scheduleId = (data["schedule_id"] ?? "").toString();
final duties = List<String>.from(data["duties"] as List? ?? []);
final busWorkNumbers = List<String>.from(data["bus_work_numbers"] as List? ?? []);
final tripMetaRaw = data["trips"] as List? ?? [];
final tripMeta = tripMetaRaw.map((t) {
@ -132,6 +136,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
setState(() {
_scheduleId = scheduleId;
_duties = duties;
_busWorkNumbers = busWorkNumbers;
_tripMeta = tripMeta;
_stopNames = stopNames;
_aliases = aliases;
@ -231,6 +236,83 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
}
}
Future<void> _loadBusDetail(String busWorkNumber) async {
final scheduleId = _scheduleId;
if (scheduleId == null) return;
setState(() {
_loadingDetail = true;
_busTrips = null;
});
try {
final response = await _invoke("operations-bus-detail", {
"channel_id": widget.channel.id,
"schedule_id": scheduleId,
"bus_work_number": busWorkNumber,
});
final data = response.data as Map?;
final tripsRaw = data?["trips"] as List? ?? [];
final snapshots = <OperationsTripSnapshot>[];
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(() {
_busTrips = snapshots;
_loadingDetail = false;
});
} catch (error, stackTrace) {
debugPrint("[OperationsChannelView] loadBusDetail failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() => _loadingDetail = false);
}
}
Future<void> _loadTripDetail(String tripNumber) async {
final scheduleId = _scheduleId;
if (scheduleId == null) return;
@ -403,15 +485,25 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
}
}
// each row is roughly 61px tall (10 vertical padding top+bottom + ~41 content)
const rowHeight = 61.0;
final offset = (bestIndex * rowHeight).clamp(
0.0,
if (bestIndex >= _stopRowKeys.length) return;
final rowCtx = _stopRowKeys[bestIndex].currentContext;
if (rowCtx == null) return;
final scrollCtx = _stopScrollViewKey.currentContext;
if (scrollCtx == null) return;
final rowBox = rowCtx.findRenderObject() as RenderBox?;
final scrollBox = scrollCtx.findRenderObject() as RenderBox?;
if (rowBox == null || scrollBox == null) return;
final rowOffset = rowBox.localToGlobal(Offset.zero, ancestor: scrollBox);
final target = (_stopScrollController.offset + rowOffset.dy).clamp(
_stopScrollController.position.minScrollExtent,
_stopScrollController.position.maxScrollExtent,
);
_stopScrollController.animateTo(
offset,
target,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
);
@ -532,6 +624,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
Divider(),
Expanded(
child: SingleChildScrollView(
key: _stopScrollViewKey,
controller: _stopScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -542,8 +635,12 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
child: Text("No scheduled times found for this stop.").small.muted,
)
else
...schedules.map(
(row) => Container(
...schedules.indexed.map(
(entry) {
final (i, row) = entry;
final rowKey = i < _stopRowKeys.length ? _stopRowKeys[i] : null;
return Container(
key: rowKey,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border(
@ -592,7 +689,8 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
Gap(2),
],
),
),
);
},
),
if (_stopLoadingMore)
@ -608,6 +706,75 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
);
}
// By Bus
if (filterBy == "By Bus") {
final selectedBus = (filterValue["By Bus"] ?? "").toString();
if (selectedBus.isEmpty) {
return Center(child: Text("Select a bus to preview.").small.muted);
}
final snapshots = _busTrips ?? const [];
if (snapshots.isEmpty) {
return Center(child: Text("No trips found for this bus.").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("Bus $selectedBus".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 Duty
if (filterBy == "By Duty") {
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
@ -754,6 +921,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
items: SelectItemList(
children: [
SelectItemButton(value: "By Duty", child: Text("By Duty")),
SelectItemButton(value: "By Bus", child: Text("By Bus")),
SelectItemButton(value: "By Trip", child: Text("By Trip")),
SelectItemButton(value: "By Stop", child: Text("By Stop")),
],
@ -771,6 +939,8 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
itemBuilder: (context, item) {
if (filterBy == "By Duty") return Text("Duty $item");
if (filterBy == "By Bus") return Text("Bus $item");
if (filterBy == "By Trip") {
final tripNumber = item.toString();
final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull;
@ -791,11 +961,14 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
setState(() {
filterValue[filterBy] = value;
_dutyTrips = null;
_busTrips = null;
_tripSnapshot = null;
_stopSchedule = null;
});
if (filterBy == "By Duty") {
unawaited(_loadDutyDetail(value.toString()));
} else if (filterBy == "By Bus") {
unawaited(_loadBusDetail(value.toString()));
} else if (filterBy == "By Trip") {
unawaited(_loadTripDetail(value.toString()));
} else if (filterBy == "By Stop") {
@ -813,6 +986,11 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
value: d,
child: Text(d),
)).toList();
} else if (filterBy == "By Bus") {
items = _busWorkNumbers.map((b) => SelectItemButton(
value: b,
child: Text(b),
)).toList();
} else if (filterBy == "By Trip") {
final trips = List.from(_tripMeta);
trips.sort((a, b) {

View file

@ -1,5 +1,6 @@
import "dart:convert";
import "package:bus_running_record/constants.dart";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:bus_running_record/provider/supabase_state.dart";
import "package:provider/provider.dart";
@ -136,7 +137,7 @@ Future<void> showJoinOrganizationDialog(BuildContext context) async {
constraints: const BoxConstraints(maxWidth: 420),
child: TextField(
autofocus: true,
placeholder: const Text("https://.../#/invite/<token>"),
placeholder: Text("https://$kAppHostname/#/invite/<token>"),
onChanged: (value) {
inviteInput = value;
},
@ -152,6 +153,13 @@ Future<void> showJoinOrganizationDialog(BuildContext context) async {
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("Cancel"),
),
Button.text(
onPressed: () {
Navigator.of(dialogContext).pop();
showCreateOrganizationDialog(context);
},
child: const Text("Create one instead"),
),
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
child: const Text("Join"),

View file

@ -48,7 +48,7 @@ class HomeLeftSidebar extends StatelessWidget {
aspectRatio: 1,
child: IconButton.outline(
onPressed: () {
unawaited(showCreateOrganizationDialog(context));
unawaited(showJoinOrganizationDialog(context));
},
shape: ButtonShape.circle,
size: ButtonSize.normal,
@ -86,43 +86,97 @@ class HomeLeftSidebar extends StatelessWidget {
Expanded(
child: Container(
color: Theme.of(context).colorScheme.background,
child: IntrinsicWidth(
child: Column(
child: IntrinsicWidth(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(topPadding),
// Organisation Name
Container(
padding: const EdgeInsets.all(8.0),
height: 170,
alignment: Alignment.bottomLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: double.infinity,
child: Button.ghost(
alignment: Alignment.center,
leading: const Icon(Icons.add),
child: const Text("Create Organization"),
padding: const EdgeInsets.only(left: 8),
child: Transform.translate(
offset: const Offset(0, 2),
child: Text(
collab.organizations.where((o) => o.id == collab.selectedOrganizationId).map((o) => o.name).firstOrNull ?? "Select a server",
style: const TextStyle(height: 1),
).extraBold.large,
),
),
Builder(
builder: (btnContext) => IconButton.ghost(
icon: Icon(LucideIcons.ellipsisVertical),
onPressed: () {
unawaited(showCreateOrganizationDialog(context));
final orgId = collab.selectedOrganizationId;
if (orgId == null) return;
final org = collab.organizations.where((o) => o.id == orgId).firstOrNull;
if (org == null) return;
showDropdown<void>(
context: btnContext,
anchorAlignment: Alignment.bottomRight,
alignment: Alignment.topLeft,
builder: (menuContext) {
return DropdownMenu(
children: [
MenuLabel(child: Text(org.name).small.semiBold),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.plus).iconSmall,
onPressed: (_) {
unawaited(showCreateChannelDialog(context, organizationId: org.id));
},
child: const Text("Add Channel"),
),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.settings2).iconSmall,
onPressed: (_) {
context.go("/org/${org.id}/settings");
},
child: const Text("Settings"),
),
],
);
},
);
},
),
),
],
),
Padding(
),
Divider(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: 8,
left: 8,
right: 8,
right: 16,
bottom: 8,
),
child: SizedBox(
width: double.infinity,
child: Button.ghost(
alignment: Alignment.center,
leading: const Icon(LucideIcons.userRoundPlus),
child: const Text("Join Organization"),
onPressed: () {
unawaited(showJoinOrganizationDialog(context));
},
child: _SelectedOrgChannelList(
organizations: organizations,
selectedOrganizationId: collab.selectedOrganizationId,
isLoading: collab.isLoadingOrganizations,
errorMessage: collab.errorMessage,
),
),
),
const Divider(),
if (false)
Padding(
padding: const EdgeInsets.only(
left: 8,
@ -141,23 +195,6 @@ class HomeLeftSidebar extends StatelessWidget {
),
),
),
const Divider(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: 8,
left: 8,
right: 16,
bottom: 8,
),
child: _OrganizationList(
organizations: organizations,
isLoading: collab.isLoadingOrganizations,
errorMessage: collab.errorMessage,
),
),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(8),
child: Button.ghost(
@ -165,7 +202,7 @@ class HomeLeftSidebar extends StatelessWidget {
children: [
Avatar(initials: initials),
const Gap(8),
Expanded(child: Basic(title: Text(displayName))),
Basic(title: Text(displayName)),
const Icon(LucideIcons.logOut).iconSmall,
],
),
@ -175,8 +212,7 @@ class HomeLeftSidebar extends StatelessWidget {
),
),
],
),
),
)),
),
),
],
@ -184,14 +220,16 @@ class HomeLeftSidebar extends StatelessWidget {
}
}
class _OrganizationList extends StatelessWidget {
const _OrganizationList({
class _SelectedOrgChannelList extends StatelessWidget {
const _SelectedOrgChannelList({
required this.organizations,
required this.selectedOrganizationId,
required this.isLoading,
required this.errorMessage,
});
final List<OrganizationSummary> organizations;
final String? selectedOrganizationId;
final bool isLoading;
final String? errorMessage;
@ -207,27 +245,27 @@ class _OrganizationList extends StatelessWidget {
);
}
if (organizations.isEmpty) {
if (selectedOrganizationId == null || organizations.isEmpty) {
return Center(
child: Text("No organizations yet. Create one to get started.").small,
child: Text("Select a server to view channels.").small.muted,
);
}
final collab = context.watch<CollaborationProvider>();
return ListView.separated(
itemBuilder: (context, index) {
final org = organizations[index];
final matchingOrgs = organizations.where((o) => o.id == selectedOrganizationId);
if (matchingOrgs.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final org = matchingOrgs.first;
return _OrganizationGroup(
organization: org,
channels: collab.channelsForOrganization(org.id),
selectedOrganizationId: collab.selectedOrganizationId,
selectedChannelId: collab.selectedChannelId,
);
},
separatorBuilder: (context, index) => const Gap(4),
itemCount: organizations.length,
);
}
}
@ -329,6 +367,19 @@ class _ServerRail extends StatelessWidget {
items: [
MenuLabel(child: Text(organization.name)),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.plus).iconSmall,
onPressed: (_) {
unawaited(
showCreateChannelDialog(
context,
organizationId: organization.id,
),
);
},
child: const Text("Add Channel"),
),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.settings2).iconSmall,
onPressed: (_) {

View file

@ -1,5 +1,6 @@
import "dart:async";
import "package:bus_running_record/constants.dart";
import "package:bus_running_record/provider/collaboration_state.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/services.dart";
@ -534,11 +535,7 @@ class _OrganizationSettingsPageState extends State<OrganizationSettingsPage> {
organizationId: organizationId,
);
if (!mounted) return;
final base = Uri.base;
final origin = base.hasAuthority
? "${base.scheme}://${base.authority}"
: "";
final inviteLink = "$origin/#/invite/$token";
final inviteLink = "https://$kAppHostname/#/invite/$token";
setState(() {
_inviteLink = inviteLink;
_message = "Invite link generated.";

2
serve_web.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/bash
flutter run -d web-server --web-port 8080

View file

@ -48,3 +48,6 @@ verify_jwt = false
[functions.operations-stop-detail]
verify_jwt = false
[functions.operations-bus-detail]
verify_jwt = false

View file

@ -0,0 +1,81 @@
import { fail, handleOptions, json } from "../_shared/http.ts";
import { requireUser } from "../_shared/supabase.ts";
Deno.serve(async (req) => {
const preflight = handleOptions(req);
if (preflight) return preflight;
if (req.method !== "POST") return fail("Method not allowed", 405);
const { client, user, error: userError } = await requireUser(req);
if (!user) return fail(userError ?? "Unauthorized", 401);
let body: { channel_id?: string; schedule_id?: string; bus_work_number?: string };
try {
body = await req.json();
} catch {
return fail("Invalid JSON body");
}
const channelId = (body.channel_id ?? "").trim();
const scheduleId = (body.schedule_id ?? "").trim();
const busWorkNumber = (body.bus_work_number ?? "").trim();
if (!channelId) return fail("channel_id is required");
if (!scheduleId) return fail("schedule_id is required");
if (!busWorkNumber) return fail("bus_work_number is required");
const { data: schedule, error: scheduleError } = await client
.from("operations_schedules")
.select("id, channel_id")
.eq("id", scheduleId)
.eq("channel_id", channelId)
.maybeSingle();
if (scheduleError) return fail(scheduleError.message, 400);
if (!schedule) return fail("forbidden", 403);
const { data: tripRows, error: tripError } = await client
.from("operations_trips")
.select("id, trip_number, duty_number, bus_work_number, direction, sort_order")
.eq("schedule_id", scheduleId)
.eq("bus_work_number", busWorkNumber)
.order("sort_order", { ascending: true });
if (tripError) return fail(tripError.message, 500);
const trips = (tripRows ?? []) as {
id: string;
trip_number: string;
duty_number: string;
bus_work_number: string;
direction: string;
sort_order: number;
}[];
if (trips.length === 0) return json({ trips: [] });
const tripIds = trips.map((t) => t.id);
const { data: stopRows, error: stopError } = await client
.from("operations_trip_stops")
.select("id, trip_id, stop_sequence, stop_name, scheduled_time")
.in("trip_id", tripIds)
.order("stop_sequence", { ascending: true });
if (stopError) return fail(stopError.message, 500);
const stopsByTripId: Record<string, typeof stopRows> = {};
for (const stop of stopRows ?? []) {
const s = stop as { trip_id: string };
if (!stopsByTripId[s.trip_id]) stopsByTripId[s.trip_id] = [];
stopsByTripId[s.trip_id]!.push(stop);
}
const result = trips.map((trip) => ({
...trip,
stops: stopsByTripId[trip.id] ?? [],
}));
return json({ trips: result });
});

View file

@ -63,6 +63,7 @@ Deno.serve(async (req) => {
}[];
const duties = [...new Set(trips.map((t) => t.duty_number))].sort();
const busWorkNumbers = [...new Set(trips.map((t) => t.bus_work_number))].sort();
const tripMeta = trips.map((t) => ({
trip_number: t.trip_number,
@ -109,6 +110,7 @@ Deno.serve(async (req) => {
has_schedule: true,
schedule_id: scheduleId,
duties,
bus_work_numbers: busWorkNumbers,
trips: tripMeta,
stop_names: stopNames,
aliases,

View file

@ -14,24 +14,23 @@ type OpenAiAlias = {
estimated?: string;
};
const prompt = `You are interpreting abbreviated station names from a rail replacement service display.
const prompt = `You are interpreting abbreviated stop names from a bus schedule display.
Each entry follows this structure:
* A 4-letter station code (derived from the real station name, often by removing vowels or compressing syllables)
* A 2-letter stop code (ignore this; it is ambiguous and not needed)
* A short stop code (typically 4 letters, derived from the stop name by removing vowels or compressing syllables)
* A 2-letter zone or bay code (ignore this; it is not needed)
* An optional "T" indicating terminus (ignore for naming purposes)
Your task is to infer the full station names from the 4-letter codes.
Your task is to infer the full stop names from the short codes.
Guidelines:
* Treat the 4-letter code as a compressed version of a real station name (e.g. consonant-heavy, missing vowels, or merged syllables)
* Use pattern recognition rather than strict decoding
* Prefer real-world plausibility over perfect letter matching
* Assume all stations are on the same rail corridor or geographically connected route
* Use the sequence of stops to inform your guesses (adjacent stations should make sense geographically)
* If a code is slightly irregular, prioritise what fits the route best over what matches the letters exactly
* Treat the code as a compressed version of a stop or locality name
* Use pattern recognition rather than strict letter matching
* Do not assume any specific country, region or city infer purely from the codes and their sequence
* Use the sequence of stops to inform your guesses (adjacent stops should make sense as a connected route)
* If a code is ambiguous, prefer the interpretation that best fits the surrounding stops
Output:
Return JSON with shape {"aliases":[{"original":"<code>","estimated":"<name>"}]}.