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.
|
/// Leave empty to disable Supabase initialization.
|
||||||
const String kSupabasePublishableKey =
|
const String kSupabasePublishableKey =
|
||||||
"sb_publishable_tqhag58YEhNrIHy264JsKg_T1VCIZib";
|
"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;
|
String? _scheduleId;
|
||||||
List<String> _duties = const [];
|
List<String> _duties = const [];
|
||||||
|
List<String> _busWorkNumbers = const [];
|
||||||
List<({String tripNumber, String dutyNumber})> _tripMeta = const [];
|
List<({String tripNumber, String dutyNumber})> _tripMeta = const [];
|
||||||
List<String> _stopNames = const [];
|
List<String> _stopNames = const [];
|
||||||
Map<String, ({String alias, String source})> _aliases = const {};
|
Map<String, ({String alias, String source})> _aliases = const {};
|
||||||
|
|
@ -33,6 +34,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
Map<String, Object?> filterValue = {};
|
Map<String, Object?> filterValue = {};
|
||||||
|
|
||||||
List<OperationsTripSnapshot>? _dutyTrips;
|
List<OperationsTripSnapshot>? _dutyTrips;
|
||||||
|
List<OperationsTripSnapshot>? _busTrips;
|
||||||
OperationsTripSnapshot? _tripSnapshot;
|
OperationsTripSnapshot? _tripSnapshot;
|
||||||
List<({OperationsTrip trip, String? time})>? _stopSchedule;
|
List<({OperationsTrip trip, String? time})>? _stopSchedule;
|
||||||
bool _stopHasMore = false;
|
bool _stopHasMore = false;
|
||||||
|
|
@ -42,6 +44,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
List<GlobalKey> _stopRowKeys = [];
|
List<GlobalKey> _stopRowKeys = [];
|
||||||
|
|
||||||
final ScrollController _stopScrollController = ScrollController();
|
final ScrollController _stopScrollController = ScrollController();
|
||||||
|
final GlobalKey _stopScrollViewKey = GlobalKey();
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -103,6 +106,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
|
|
||||||
final scheduleId = (data["schedule_id"] ?? "").toString();
|
final scheduleId = (data["schedule_id"] ?? "").toString();
|
||||||
final duties = List<String>.from(data["duties"] as List? ?? []);
|
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 tripMetaRaw = data["trips"] as List? ?? [];
|
||||||
final tripMeta = tripMetaRaw.map((t) {
|
final tripMeta = tripMetaRaw.map((t) {
|
||||||
|
|
@ -132,6 +136,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scheduleId = scheduleId;
|
_scheduleId = scheduleId;
|
||||||
_duties = duties;
|
_duties = duties;
|
||||||
|
_busWorkNumbers = busWorkNumbers;
|
||||||
_tripMeta = tripMeta;
|
_tripMeta = tripMeta;
|
||||||
_stopNames = stopNames;
|
_stopNames = stopNames;
|
||||||
_aliases = aliases;
|
_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 {
|
Future<void> _loadTripDetail(String tripNumber) async {
|
||||||
final scheduleId = _scheduleId;
|
final scheduleId = _scheduleId;
|
||||||
if (scheduleId == null) return;
|
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)
|
if (bestIndex >= _stopRowKeys.length) return;
|
||||||
const rowHeight = 61.0;
|
final rowCtx = _stopRowKeys[bestIndex].currentContext;
|
||||||
final offset = (bestIndex * rowHeight).clamp(
|
if (rowCtx == null) return;
|
||||||
0.0,
|
|
||||||
|
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.position.maxScrollExtent,
|
||||||
);
|
);
|
||||||
|
|
||||||
_stopScrollController.animateTo(
|
_stopScrollController.animateTo(
|
||||||
offset,
|
target,
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
);
|
);
|
||||||
|
|
@ -532,6 +624,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
Divider(),
|
Divider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
key: _stopScrollViewKey,
|
||||||
controller: _stopScrollController,
|
controller: _stopScrollController,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -542,8 +635,12 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
child: Text("No scheduled times found for this stop.").small.muted,
|
child: Text("No scheduled times found for this stop.").small.muted,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
...schedules.map(
|
...schedules.indexed.map(
|
||||||
(row) => Container(
|
(entry) {
|
||||||
|
final (i, row) = entry;
|
||||||
|
final rowKey = i < _stopRowKeys.length ? _stopRowKeys[i] : null;
|
||||||
|
return Container(
|
||||||
|
key: rowKey,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
|
|
@ -592,7 +689,8 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
Gap(2),
|
Gap(2),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (_stopLoadingMore)
|
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
|
// By Duty
|
||||||
if (filterBy == "By Duty") {
|
if (filterBy == "By Duty") {
|
||||||
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
|
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
|
||||||
|
|
@ -754,6 +921,7 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
items: SelectItemList(
|
items: SelectItemList(
|
||||||
children: [
|
children: [
|
||||||
SelectItemButton(value: "By Duty", child: Text("By Duty")),
|
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 Trip", child: Text("By Trip")),
|
||||||
SelectItemButton(value: "By Stop", child: Text("By Stop")),
|
SelectItemButton(value: "By Stop", child: Text("By Stop")),
|
||||||
],
|
],
|
||||||
|
|
@ -771,6 +939,8 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
itemBuilder: (context, item) {
|
itemBuilder: (context, item) {
|
||||||
if (filterBy == "By Duty") return Text("Duty $item");
|
if (filterBy == "By Duty") return Text("Duty $item");
|
||||||
|
|
||||||
|
if (filterBy == "By Bus") return Text("Bus $item");
|
||||||
|
|
||||||
if (filterBy == "By Trip") {
|
if (filterBy == "By Trip") {
|
||||||
final tripNumber = item.toString();
|
final tripNumber = item.toString();
|
||||||
final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull;
|
final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull;
|
||||||
|
|
@ -791,11 +961,14 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
setState(() {
|
setState(() {
|
||||||
filterValue[filterBy] = value;
|
filterValue[filterBy] = value;
|
||||||
_dutyTrips = null;
|
_dutyTrips = null;
|
||||||
|
_busTrips = null;
|
||||||
_tripSnapshot = null;
|
_tripSnapshot = null;
|
||||||
_stopSchedule = null;
|
_stopSchedule = null;
|
||||||
});
|
});
|
||||||
if (filterBy == "By Duty") {
|
if (filterBy == "By Duty") {
|
||||||
unawaited(_loadDutyDetail(value.toString()));
|
unawaited(_loadDutyDetail(value.toString()));
|
||||||
|
} else if (filterBy == "By Bus") {
|
||||||
|
unawaited(_loadBusDetail(value.toString()));
|
||||||
} else if (filterBy == "By Trip") {
|
} else if (filterBy == "By Trip") {
|
||||||
unawaited(_loadTripDetail(value.toString()));
|
unawaited(_loadTripDetail(value.toString()));
|
||||||
} else if (filterBy == "By Stop") {
|
} else if (filterBy == "By Stop") {
|
||||||
|
|
@ -813,6 +986,11 @@ class _OperationsChannelViewState extends State<OperationsChannelView> {
|
||||||
value: d,
|
value: d,
|
||||||
child: Text(d),
|
child: Text(d),
|
||||||
)).toList();
|
)).toList();
|
||||||
|
} else if (filterBy == "By Bus") {
|
||||||
|
items = _busWorkNumbers.map((b) => SelectItemButton(
|
||||||
|
value: b,
|
||||||
|
child: Text(b),
|
||||||
|
)).toList();
|
||||||
} else if (filterBy == "By Trip") {
|
} else if (filterBy == "By Trip") {
|
||||||
final trips = List.from(_tripMeta);
|
final trips = List.from(_tripMeta);
|
||||||
trips.sort((a, b) {
|
trips.sort((a, b) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "dart:convert";
|
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/collaboration_state.dart";
|
||||||
import "package:bus_running_record/provider/supabase_state.dart";
|
import "package:bus_running_record/provider/supabase_state.dart";
|
||||||
import "package:provider/provider.dart";
|
import "package:provider/provider.dart";
|
||||||
|
|
@ -136,7 +137,7 @@ Future<void> showJoinOrganizationDialog(BuildContext context) async {
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
placeholder: const Text("https://.../#/invite/<token>"),
|
placeholder: Text("https://$kAppHostname/#/invite/<token>"),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
inviteInput = value;
|
inviteInput = value;
|
||||||
},
|
},
|
||||||
|
|
@ -152,6 +153,13 @@ Future<void> showJoinOrganizationDialog(BuildContext context) async {
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text("Cancel"),
|
child: const Text("Cancel"),
|
||||||
),
|
),
|
||||||
|
Button.text(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
showCreateOrganizationDialog(context);
|
||||||
|
},
|
||||||
|
child: const Text("Create one instead"),
|
||||||
|
),
|
||||||
Button.primary(
|
Button.primary(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
|
onPressed: () => Navigator.of(dialogContext).pop(inviteInput),
|
||||||
child: const Text("Join"),
|
child: const Text("Join"),
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class HomeLeftSidebar extends StatelessWidget {
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: IconButton.outline(
|
child: IconButton.outline(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
unawaited(showCreateOrganizationDialog(context));
|
unawaited(showJoinOrganizationDialog(context));
|
||||||
},
|
},
|
||||||
shape: ButtonShape.circle,
|
shape: ButtonShape.circle,
|
||||||
size: ButtonSize.normal,
|
size: ButtonSize.normal,
|
||||||
|
|
@ -86,43 +86,97 @@ class HomeLeftSidebar extends StatelessWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Theme.of(context).colorScheme.background,
|
color: Theme.of(context).colorScheme.background,
|
||||||
child: IntrinsicWidth(
|
child: IntrinsicWidth(child: Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
||||||
children: [
|
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(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: SizedBox(
|
child: Transform.translate(
|
||||||
width: double.infinity,
|
offset: const Offset(0, 2),
|
||||||
child: Button.ghost(
|
child: Text(
|
||||||
alignment: Alignment.center,
|
collab.organizations.where((o) => o.id == collab.selectedOrganizationId).map((o) => o.name).firstOrNull ?? "Select a server",
|
||||||
leading: const Icon(Icons.add),
|
style: const TextStyle(height: 1),
|
||||||
child: const Text("Create Organization"),
|
).extraBold.large,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Builder(
|
||||||
|
builder: (btnContext) => IconButton.ghost(
|
||||||
|
icon: Icon(LucideIcons.ellipsisVertical),
|
||||||
onPressed: () {
|
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(
|
padding: const EdgeInsets.only(
|
||||||
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 8,
|
right: 16,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
),
|
),
|
||||||
child: SizedBox(
|
child: _SelectedOrgChannelList(
|
||||||
width: double.infinity,
|
organizations: organizations,
|
||||||
child: Button.ghost(
|
selectedOrganizationId: collab.selectedOrganizationId,
|
||||||
alignment: Alignment.center,
|
isLoading: collab.isLoadingOrganizations,
|
||||||
leading: const Icon(LucideIcons.userRoundPlus),
|
errorMessage: collab.errorMessage,
|
||||||
child: const Text("Join Organization"),
|
|
||||||
onPressed: () {
|
|
||||||
unawaited(showJoinOrganizationDialog(context));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
if (false)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 8,
|
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(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Button.ghost(
|
child: Button.ghost(
|
||||||
|
|
@ -165,7 +202,7 @@ class HomeLeftSidebar extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Avatar(initials: initials),
|
Avatar(initials: initials),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Expanded(child: Basic(title: Text(displayName))),
|
Basic(title: Text(displayName)),
|
||||||
const Icon(LucideIcons.logOut).iconSmall,
|
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 {
|
class _SelectedOrgChannelList extends StatelessWidget {
|
||||||
const _OrganizationList({
|
const _SelectedOrgChannelList({
|
||||||
required this.organizations,
|
required this.organizations,
|
||||||
|
required this.selectedOrganizationId,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.errorMessage,
|
required this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<OrganizationSummary> organizations;
|
final List<OrganizationSummary> organizations;
|
||||||
|
final String? selectedOrganizationId;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
|
|
@ -207,27 +245,27 @@ class _OrganizationList extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizations.isEmpty) {
|
if (selectedOrganizationId == null || organizations.isEmpty) {
|
||||||
return Center(
|
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>();
|
final collab = context.watch<CollaborationProvider>();
|
||||||
|
|
||||||
return ListView.separated(
|
final matchingOrgs = organizations.where((o) => o.id == selectedOrganizationId);
|
||||||
itemBuilder: (context, index) {
|
if (matchingOrgs.isEmpty) {
|
||||||
final org = organizations[index];
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final org = matchingOrgs.first;
|
||||||
|
|
||||||
return _OrganizationGroup(
|
return _OrganizationGroup(
|
||||||
organization: org,
|
organization: org,
|
||||||
channels: collab.channelsForOrganization(org.id),
|
channels: collab.channelsForOrganization(org.id),
|
||||||
selectedOrganizationId: collab.selectedOrganizationId,
|
selectedOrganizationId: collab.selectedOrganizationId,
|
||||||
selectedChannelId: collab.selectedChannelId,
|
selectedChannelId: collab.selectedChannelId,
|
||||||
);
|
);
|
||||||
},
|
|
||||||
separatorBuilder: (context, index) => const Gap(4),
|
|
||||||
itemCount: organizations.length,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,6 +367,19 @@ class _ServerRail extends StatelessWidget {
|
||||||
items: [
|
items: [
|
||||||
MenuLabel(child: Text(organization.name)),
|
MenuLabel(child: Text(organization.name)),
|
||||||
const MenuDivider(),
|
const MenuDivider(),
|
||||||
|
MenuButton(
|
||||||
|
leading: const Icon(LucideIcons.plus).iconSmall,
|
||||||
|
onPressed: (_) {
|
||||||
|
unawaited(
|
||||||
|
showCreateChannelDialog(
|
||||||
|
context,
|
||||||
|
organizationId: organization.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text("Add Channel"),
|
||||||
|
),
|
||||||
|
const MenuDivider(),
|
||||||
MenuButton(
|
MenuButton(
|
||||||
leading: const Icon(LucideIcons.settings2).iconSmall,
|
leading: const Icon(LucideIcons.settings2).iconSmall,
|
||||||
onPressed: (_) {
|
onPressed: (_) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:bus_running_record/constants.dart";
|
||||||
import "package:bus_running_record/provider/collaboration_state.dart";
|
import "package:bus_running_record/provider/collaboration_state.dart";
|
||||||
import "package:file_picker/file_picker.dart";
|
import "package:file_picker/file_picker.dart";
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
|
|
@ -534,11 +535,7 @@ class _OrganizationSettingsPageState extends State<OrganizationSettingsPage> {
|
||||||
organizationId: organizationId,
|
organizationId: organizationId,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final base = Uri.base;
|
final inviteLink = "https://$kAppHostname/#/invite/$token";
|
||||||
final origin = base.hasAuthority
|
|
||||||
? "${base.scheme}://${base.authority}"
|
|
||||||
: "";
|
|
||||||
final inviteLink = "$origin/#/invite/$token";
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_inviteLink = inviteLink;
|
_inviteLink = inviteLink;
|
||||||
_message = "Invite link generated.";
|
_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]
|
[functions.operations-stop-detail]
|
||||||
verify_jwt = false
|
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 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) => ({
|
const tripMeta = trips.map((t) => ({
|
||||||
trip_number: t.trip_number,
|
trip_number: t.trip_number,
|
||||||
|
|
@ -109,6 +110,7 @@ Deno.serve(async (req) => {
|
||||||
has_schedule: true,
|
has_schedule: true,
|
||||||
schedule_id: scheduleId,
|
schedule_id: scheduleId,
|
||||||
duties,
|
duties,
|
||||||
|
bus_work_numbers: busWorkNumbers,
|
||||||
trips: tripMeta,
|
trips: tripMeta,
|
||||||
stop_names: stopNames,
|
stop_names: stopNames,
|
||||||
aliases,
|
aliases,
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,23 @@ type OpenAiAlias = {
|
||||||
estimated?: string;
|
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:
|
Each entry follows this structure:
|
||||||
|
|
||||||
* A 4-letter station code (derived from the real station name, often by removing vowels or compressing syllables)
|
* A short stop code (typically 4 letters, derived from the stop name by removing vowels or compressing syllables)
|
||||||
* A 2-letter stop code (ignore this; it is ambiguous and not needed)
|
* A 2-letter zone or bay code (ignore this; it is not needed)
|
||||||
* An optional "T" indicating terminus (ignore for naming purposes)
|
* 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:
|
Guidelines:
|
||||||
|
|
||||||
* Treat the 4-letter code as a compressed version of a real station name (e.g. consonant-heavy, missing vowels, or merged syllables)
|
* Treat the code as a compressed version of a stop or locality name
|
||||||
* Use pattern recognition rather than strict decoding
|
* Use pattern recognition rather than strict letter matching
|
||||||
* Prefer real-world plausibility over perfect letter matching
|
* Do not assume any specific country, region or city — infer purely from the codes and their sequence
|
||||||
* Assume all stations are on the same rail corridor or geographically connected route
|
* Use the sequence of stops to inform your guesses (adjacent stops should make sense as a connected route)
|
||||||
* Use the sequence of stops to inform your guesses (adjacent stations should make sense geographically)
|
* If a code is ambiguous, prefer the interpretation that best fits the surrounding stops
|
||||||
* If a code is slightly irregular, prioritise what fits the route best over what matches the letters exactly
|
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
Return JSON with shape {"aliases":[{"original":"<code>","estimated":"<name>"}]}.
|
Return JSON with shape {"aliases":[{"original":"<code>","estimated":"<name>"}]}.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue