Add bus detail functionality and update organization selection UI
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user