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
@@ -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) {