1028 lines
37 KiB
Dart
1028 lines
37 KiB
Dart
import "dart:async";
|
|
import "dart:math";
|
|
|
|
import "package:bus_running_record/models/channels/operations_channel.dart";
|
|
import "package:bus_running_record/models/operations/stop.dart";
|
|
import "package:bus_running_record/widgets/trip_diagram.dart";
|
|
import "package:flutter/foundation.dart";
|
|
import "package:go_router/go_router.dart";
|
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
|
|
|
|
|
class OperationsChannelView extends StatefulWidget {
|
|
const OperationsChannelView({required this.channel, super.key});
|
|
|
|
final OperationsChannel channel;
|
|
|
|
@override
|
|
State<OperationsChannelView> createState() => _OperationsChannelViewState();
|
|
}
|
|
|
|
class _OperationsChannelViewState extends State<OperationsChannelView> {
|
|
|
|
bool _loadingMeta = true;
|
|
bool _loadingDetail = false;
|
|
|
|
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 {};
|
|
|
|
String filterBy = "By Duty";
|
|
Map<String, Object?> filterValue = {};
|
|
|
|
List<OperationsTripSnapshot>? _dutyTrips;
|
|
List<OperationsTripSnapshot>? _busTrips;
|
|
OperationsTripSnapshot? _tripSnapshot;
|
|
List<({OperationsTrip trip, String? time})>? _stopSchedule;
|
|
bool _stopHasMore = false;
|
|
bool _stopLoadingMore = false;
|
|
int _stopOffset = 0;
|
|
static const int _stopPageSize = 20;
|
|
List<GlobalKey> _stopRowKeys = [];
|
|
|
|
final ScrollController _stopScrollController = ScrollController();
|
|
final GlobalKey _stopScrollViewKey = GlobalKey();
|
|
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_stopScrollController.addListener(_onStopScroll);
|
|
unawaited(_loadMeta());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onStopScroll() {
|
|
if (!_stopHasMore || _stopLoadingMore || _loadingDetail) return;
|
|
final pos = _stopScrollController.position;
|
|
if (pos.pixels >= pos.maxScrollExtent - 100) {
|
|
unawaited(_loadMoreStop());
|
|
}
|
|
}
|
|
|
|
Future<dynamic> _invoke(String functionName, Map<String, dynamic> body) async {
|
|
final client = widget.channel.client;
|
|
client.functions.setAuth(
|
|
client.auth.currentSession?.accessToken ?? "",
|
|
);
|
|
return client.functions.invoke(
|
|
functionName,
|
|
body: body,
|
|
headers: {
|
|
"Authorization": "Bearer ${client.auth.currentSession?.accessToken ?? ""}",
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _loadMeta() async {
|
|
setState(() {
|
|
_loadingMeta = true;
|
|
});
|
|
|
|
try {
|
|
final response = await _invoke("operations-schedule-meta", {
|
|
"channel_id": widget.channel.id,
|
|
});
|
|
|
|
final data = response.data as Map?;
|
|
if (data == null) throw StateError("Empty response from meta function.");
|
|
|
|
if (data["has_schedule"] != true) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_scheduleId = null;
|
|
_loadingMeta = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
final m = t as Map;
|
|
return (
|
|
tripNumber: (m["trip_number"] ?? "").toString(),
|
|
dutyNumber: (m["duty_number"] ?? "").toString(),
|
|
);
|
|
}).toList();
|
|
|
|
final stopNames = List<String>.from(data["stop_names"] as List? ?? []);
|
|
|
|
final aliasesRaw = data["aliases"] as List? ?? [];
|
|
final aliases = <String, ({String alias, String source})>{};
|
|
for (final row in aliasesRaw) {
|
|
final m = row as Map;
|
|
final raw = (m["raw_stop_name"] ?? "").toString().trim();
|
|
final alias = (m["alias_stop_name"] ?? "").toString().trim();
|
|
final source = (m["source"] ?? "user").toString();
|
|
if (raw.isEmpty || alias.isEmpty) continue;
|
|
aliases[Stop.normalizeName(raw)] = (alias: alias, source: source);
|
|
}
|
|
|
|
final firstDuty = duties.isNotEmpty ? duties.first : null;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_scheduleId = scheduleId;
|
|
_duties = duties;
|
|
_busWorkNumbers = busWorkNumbers;
|
|
_tripMeta = tripMeta;
|
|
_stopNames = stopNames;
|
|
_aliases = aliases;
|
|
_loadingMeta = false;
|
|
|
|
if (firstDuty != null) {
|
|
filterBy = "By Duty";
|
|
filterValue["By Duty"] = firstDuty;
|
|
}
|
|
});
|
|
|
|
if (firstDuty != null) {
|
|
unawaited(_loadDutyDetail(firstDuty));
|
|
}
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsChannelView] loadMeta failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() => _loadingMeta = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadDutyDetail(String dutyNumber) async {
|
|
final scheduleId = _scheduleId;
|
|
if (scheduleId == null) return;
|
|
|
|
setState(() {
|
|
_loadingDetail = true;
|
|
_dutyTrips = null;
|
|
});
|
|
|
|
try {
|
|
final response = await _invoke("operations-duty-detail", {
|
|
"channel_id": widget.channel.id,
|
|
"schedule_id": scheduleId,
|
|
"duty_number": dutyNumber,
|
|
});
|
|
|
|
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(() {
|
|
_dutyTrips = snapshots;
|
|
_loadingDetail = false;
|
|
});
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsChannelView] loadDutyDetail failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() => _loadingDetail = false);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
setState(() {
|
|
_loadingDetail = true;
|
|
_tripSnapshot = null;
|
|
});
|
|
|
|
try {
|
|
final response = await _invoke("operations-trip-detail", {
|
|
"channel_id": widget.channel.id,
|
|
"schedule_id": scheduleId,
|
|
"trip_number": tripNumber,
|
|
});
|
|
|
|
final data = response.data as Map?;
|
|
final tripData = data?["trip"] as Map?;
|
|
if (tripData == null) {
|
|
if (!mounted) return;
|
|
setState(() => _loadingDetail = false);
|
|
return;
|
|
}
|
|
|
|
final trip = OperationsTrip(
|
|
id: (tripData["id"] ?? "").toString(),
|
|
scheduleId: scheduleId,
|
|
tripNumber: (tripData["trip_number"] ?? "").toString(),
|
|
dutyNumber: (tripData["duty_number"] ?? "").toString(),
|
|
busWorkNumber: (tripData["bus_work_number"] ?? "").toString(),
|
|
direction: (tripData["direction"] ?? "").toString(),
|
|
sortOrder: (tripData["sort_order"] as num?)?.toInt() ?? 0,
|
|
);
|
|
|
|
final stopsRaw = data?["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();
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_tripSnapshot = OperationsTripSnapshot(
|
|
trip: trip,
|
|
stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(),
|
|
scheduledStops: stops,
|
|
);
|
|
_loadingDetail = false;
|
|
});
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsChannelView] loadTripDetail failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() => _loadingDetail = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadStopDetail(String stopName) async {
|
|
final scheduleId = _scheduleId;
|
|
if (scheduleId == null) return;
|
|
|
|
setState(() {
|
|
_loadingDetail = true;
|
|
_stopSchedule = null;
|
|
_stopOffset = 0;
|
|
_stopHasMore = false;
|
|
});
|
|
|
|
try {
|
|
final response = await _invoke("operations-stop-detail", {
|
|
"channel_id": widget.channel.id,
|
|
"schedule_id": scheduleId,
|
|
"stop_name": stopName,
|
|
"offset": 0,
|
|
"limit": _stopPageSize,
|
|
});
|
|
|
|
final data = response.data as Map?;
|
|
final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []);
|
|
final hasMore = data?["has_more"] == true;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_stopSchedule = page;
|
|
_stopOffset = page.length;
|
|
_stopHasMore = hasMore;
|
|
_stopRowKeys = List.generate(page.length, (_) => GlobalKey());
|
|
_loadingDetail = false;
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToClosestTime(page));
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsChannelView] loadStopDetail failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() => _loadingDetail = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMoreStop() async {
|
|
final scheduleId = _scheduleId;
|
|
if (scheduleId == null || !_stopHasMore || _stopLoadingMore) return;
|
|
final stopName = (filterValue["By Stop"] ?? "").toString().trim();
|
|
if (stopName.isEmpty) return;
|
|
|
|
setState(() => _stopLoadingMore = true);
|
|
|
|
try {
|
|
final response = await _invoke("operations-stop-detail", {
|
|
"channel_id": widget.channel.id,
|
|
"schedule_id": scheduleId,
|
|
"stop_name": stopName,
|
|
"offset": _stopOffset,
|
|
"limit": _stopPageSize,
|
|
});
|
|
|
|
final data = response.data as Map?;
|
|
final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []);
|
|
final hasMore = data?["has_more"] == true;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_stopSchedule = [...?_stopSchedule, ...page];
|
|
_stopOffset += page.length;
|
|
_stopHasMore = hasMore;
|
|
_stopLoadingMore = false;
|
|
});
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsChannelView] loadMoreStop failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() => _stopLoadingMore = false);
|
|
}
|
|
}
|
|
|
|
void _scrollToClosestTime(List<({OperationsTrip trip, String? time})> schedule) {
|
|
if (!_stopScrollController.hasClients) return;
|
|
if (schedule.isEmpty) return;
|
|
|
|
final now = DateTime.now();
|
|
final nowMins = now.hour * 60 + now.minute;
|
|
|
|
int bestIndex = 0;
|
|
int bestDiff = 9999;
|
|
|
|
for (var i = 0; i < schedule.length; i++) {
|
|
final time = schedule[i].time;
|
|
if (time == null) continue;
|
|
final parts = time.split(":");
|
|
if (parts.length < 2) continue;
|
|
final h = int.tryParse(parts[0]);
|
|
final m = int.tryParse(parts[1]);
|
|
if (h == null || m == null) continue;
|
|
final diff = ((h * 60 + m) - nowMins).abs();
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
|
|
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(
|
|
target,
|
|
duration: const Duration(milliseconds: 350),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
|
|
List<({OperationsTrip trip, String? time})> _parseStopSchedule(
|
|
String scheduleId,
|
|
List scheduleRaw,
|
|
) {
|
|
return scheduleRaw.map((row) {
|
|
final m = row as Map;
|
|
final tripData = m["trip"] as Map;
|
|
final trip = OperationsTrip(
|
|
id: (tripData["id"] ?? "").toString(),
|
|
scheduleId: scheduleId,
|
|
tripNumber: (tripData["trip_number"] ?? "").toString(),
|
|
dutyNumber: (tripData["duty_number"] ?? "").toString(),
|
|
busWorkNumber: (tripData["bus_work_number"] ?? "").toString(),
|
|
direction: (tripData["direction"] ?? "").toString(),
|
|
sortOrder: 0,
|
|
);
|
|
final time = (m["scheduled_time"] ?? "").toString().trim();
|
|
return (trip: trip, time: time.isEmpty ? null : time);
|
|
}).toList();
|
|
}
|
|
|
|
String? _aliasForStop(String rawName) =>
|
|
_aliases[Stop.normalizeName(rawName)]?.alias;
|
|
|
|
Widget _stopLabel(String rawName) {
|
|
final alias = _aliasForStop(rawName);
|
|
if (alias == null || alias.isEmpty) return Text(rawName);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(alias).semiBold,
|
|
if (alias != rawName)
|
|
Text(
|
|
rawName,
|
|
style: const TextStyle(fontSize: 11, height: 1),
|
|
).xSmall.muted.semiBold,
|
|
],
|
|
);
|
|
}
|
|
|
|
List<TripDiagramEntry> _diagramEntries(List<OperationsScheduledStop> stops) {
|
|
return stops.map((stop) => TripDiagramEntry(
|
|
label: stop.displayName,
|
|
labelIcon: stop.aliasSource == "ai" ? LucideIcons.sparkles : null,
|
|
subtitle: stop.displayName == stop.name ? null : stop.name,
|
|
time: stop.scheduledTime,
|
|
)).toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS ||
|
|
defaultTargetPlatform == TargetPlatform.android;
|
|
|
|
if (_loadingMeta) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_scheduleId == null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text("No schedule uploaded yet.").h4,
|
|
Gap(8),
|
|
Button.secondary(
|
|
onPressed: () => context.go(
|
|
"/channel/${widget.channel.organizationId}/${widget.channel.id}/upload",
|
|
),
|
|
child: const Text("Upload Schedule"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
|
|
Expanded(
|
|
child: Builder(
|
|
builder: (context) {
|
|
|
|
if (_loadingDetail) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
// By Stop
|
|
if (filterBy == "By Stop") {
|
|
final selectedStop = (filterValue["By Stop"] ?? "").toString().trim();
|
|
if (selectedStop.isEmpty) {
|
|
return Center(child: Text("Select a stop to preview its schedule.").small.muted);
|
|
}
|
|
|
|
final schedules = _stopSchedule ?? const [];
|
|
return Column(
|
|
children: [
|
|
Gap(8),
|
|
Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(LucideIcons.pin),
|
|
Gap(8),
|
|
_stopLabel(selectedStop),
|
|
],
|
|
),
|
|
),
|
|
Divider(),
|
|
Gap(2),
|
|
Divider(),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
key: _stopScrollViewKey,
|
|
controller: _stopScrollController,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (schedules.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text("No scheduled times found for this stop.").small.muted,
|
|
)
|
|
else
|
|
...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(
|
|
bottom: BorderSide(
|
|
color: Colors.white.withValues(alpha: 0.07),
|
|
),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Gap(16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text("${row.trip.dutyNumber} • ").extraBold,
|
|
Text(
|
|
"Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}",
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
).muted,
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
Text(row.trip.direction).muted.textSmall,
|
|
if (row.trip.direction.toLowerCase().contains("out")) ...[
|
|
Gap(4),
|
|
Icon(LucideIcons.arrowUpRight).iconSmall,
|
|
] else if (row.trip.direction.toLowerCase().contains("in")) ...[
|
|
Gap(4),
|
|
Icon(LucideIcons.arrowDownLeft).iconSmall,
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(row.time ?? "--:--").extraBold.mono,
|
|
Gap(2),
|
|
Icon(LucideIcons.chevronRight),
|
|
Gap(2),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
if (_stopLoadingMore)
|
|
const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 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();
|
|
if (selectedDuty.isEmpty) {
|
|
return Center(child: Text("Select a duty to preview.").small.muted);
|
|
}
|
|
|
|
final snapshots = _dutyTrips ?? const [];
|
|
if (snapshots.isEmpty) {
|
|
return Center(child: Text("No trips found for this duty.").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("Duty $selectedDuty".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 Trip
|
|
if (filterBy == "By Trip") {
|
|
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
|
|
if (selectedTripNumber.isEmpty) {
|
|
return Center(child: Text("Select a trip to preview.").small.muted);
|
|
}
|
|
|
|
final snapshot = _tripSnapshot;
|
|
if (snapshot == null) {
|
|
return Center(child: Text("No data for this trip.").small.muted);
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Gap(8),
|
|
Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(LucideIcons.bus),
|
|
Gap(8),
|
|
Text(
|
|
"Trip ${snapshot.trip.tripNumber} • Duty ${snapshot.trip.dutyNumber}",
|
|
).semiBold.textSmall,
|
|
],
|
|
),
|
|
),
|
|
Divider(),
|
|
TripDiagram(
|
|
lineColor: Colors.red,
|
|
entries: _diagramEntries(snapshot.scheduledStops),
|
|
leftOffset: 12,
|
|
rowHeight: 48,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Center(
|
|
child: Text("Select a filter to preview the schedule.").small.muted,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
Divider(),
|
|
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: Select<String>(
|
|
itemBuilder: (context, item) => Text("Filter by: $item"),
|
|
popupConstraints: BoxConstraints(
|
|
maxHeight: 300,
|
|
maxWidth: !isMobile ? 200 : double.infinity,
|
|
),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
filterBy = value ?? filterBy;
|
|
});
|
|
},
|
|
value: filterBy,
|
|
popup: SelectPopup(
|
|
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")),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
Gap(16),
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: Select<Object>(
|
|
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;
|
|
if (match == null) return Text("Trip $tripNumber");
|
|
return Text("Trip ${match.tripNumber} • Duty ${match.dutyNumber}");
|
|
}
|
|
|
|
if (filterBy == "By Stop") return _stopLabel(item.toString());
|
|
|
|
return Text("Undefined");
|
|
},
|
|
popupConstraints: BoxConstraints(
|
|
maxHeight: 300,
|
|
maxWidth: !isMobile ? 200 : double.infinity,
|
|
),
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
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") {
|
|
unawaited(_loadStopDetail(value.toString()));
|
|
}
|
|
},
|
|
value: filterValue[filterBy],
|
|
popup: SelectPopup.builder(
|
|
searchPlaceholder: Text("Search ${filterBy.toLowerCase()}"),
|
|
builder: (context, searchQuery) {
|
|
List<SelectItemButton> items = [];
|
|
|
|
if (filterBy == "By Duty") {
|
|
items = _duties.map((d) => SelectItemButton(
|
|
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) {
|
|
final aNum = int.tryParse(a.tripNumber);
|
|
final bNum = int.tryParse(b.tripNumber);
|
|
if (aNum != null && bNum != null) return aNum.compareTo(bNum);
|
|
return a.tripNumber.compareTo(b.tripNumber);
|
|
});
|
|
items = trips.map((t) => SelectItemButton(
|
|
value: t.tripNumber,
|
|
child: Text("Trip ${t.tripNumber}"),
|
|
)).toList();
|
|
} else if (filterBy == "By Stop") {
|
|
items = _stopNames.map((s) => SelectItemButton(
|
|
value: s,
|
|
child: _stopLabel(s),
|
|
)).toList();
|
|
}
|
|
|
|
return SelectItemList(children: items);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
],
|
|
),
|
|
),
|
|
|
|
Gap(max(bottomPadding, 16)),
|
|
|
|
],
|
|
);
|
|
}
|
|
}
|