Add bus detail functionality and update organization selection UI
This commit is contained in:
parent
55cd970173
commit
3dfea45afb
10 changed files with 425 additions and 97 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,62 +86,79 @@ 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),
|
||||
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"),
|
||||
onPressed: () {
|
||||
unawaited(showCreateOrganizationDialog(context));
|
||||
},
|
||||
),
|
||||
|
||||
// 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.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: () {
|
||||
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(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
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));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.secondary(
|
||||
alignment: Alignment.center,
|
||||
leading: const Icon(LucideIcons.bug),
|
||||
child: const Text("Auth Debug"),
|
||||
onPressed: () {
|
||||
unawaited(runAuthDebug(context));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
|
||||
Divider(),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
|
|
@ -150,14 +167,34 @@ class HomeLeftSidebar extends StatelessWidget {
|
|||
right: 16,
|
||||
bottom: 8,
|
||||
),
|
||||
child: _OrganizationList(
|
||||
child: _SelectedOrgChannelList(
|
||||
organizations: organizations,
|
||||
selectedOrganizationId: collab.selectedOrganizationId,
|
||||
isLoading: collab.isLoadingOrganizations,
|
||||
errorMessage: collab.errorMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.secondary(
|
||||
alignment: Alignment.center,
|
||||
leading: const Icon(LucideIcons.bug),
|
||||
child: const Text("Auth Debug"),
|
||||
onPressed: () {
|
||||
unawaited(runAuthDebug(context));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
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,26 +245,26 @@ 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];
|
||||
return _OrganizationGroup(
|
||||
organization: org,
|
||||
channels: collab.channelsForOrganization(org.id),
|
||||
selectedOrganizationId: collab.selectedOrganizationId,
|
||||
selectedChannelId: collab.selectedChannelId,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Gap(4),
|
||||
itemCount: organizations.length,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: (_) {
|
||||
|
|
|
|||
|
|
@ -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
2
serve_web.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash
|
||||
flutter run -d web-server --web-port 8080
|
||||
|
|
@ -48,3 +48,6 @@ verify_jwt = false
|
|||
|
||||
[functions.operations-stop-detail]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.operations-bus-detail]
|
||||
verify_jwt = false
|
||||
|
|
|
|||
81
supabase/functions/operations-bus-detail/index.ts
Normal file
81
supabase/functions/operations-bus-detail/index.ts
Normal 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 });
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>"}]}.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue