1070 lines
35 KiB
Dart
1070 lines
35 KiB
Dart
import "dart:async";
|
|
import "dart:convert";
|
|
import "dart:io";
|
|
import "dart:math";
|
|
|
|
import "package:bus_running_record/models/operations/scheduled_stop.dart";
|
|
import "package:bus_running_record/models/operations/trip.dart";
|
|
import "package:bus_running_record/parsers/arriva_schedule_parser.dart";
|
|
import "package:bus_running_record/parsers/stagecoach_schedule_parser.dart";
|
|
import "package:bus_running_record/provider/supabase_state.dart";
|
|
import "package:bus_running_record/widgets/trip_diagram.dart";
|
|
import "package:file_picker/file_picker.dart";
|
|
import "package:flutter/foundation.dart";
|
|
import "package:go_router/go_router.dart";
|
|
import "package:provider/provider.dart";
|
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
|
|
|
class OperationsUploadPage extends StatefulWidget {
|
|
const OperationsUploadPage({
|
|
required this.organizationId,
|
|
required this.channelId,
|
|
super.key,
|
|
});
|
|
|
|
final String organizationId;
|
|
final String channelId;
|
|
|
|
static final GoRoute route = GoRoute(
|
|
path: "/channel/:orgId/:channelId/upload",
|
|
builder: (context, state) => OperationsUploadPage(
|
|
organizationId: state.pathParameters["orgId"] ?? "",
|
|
channelId: state.pathParameters["channelId"] ?? "",
|
|
),
|
|
);
|
|
|
|
@override
|
|
State<OperationsUploadPage> createState() => _OperationsUploadPageState();
|
|
}
|
|
|
|
class _OperationsUploadPageState extends State<OperationsUploadPage> {
|
|
bool _parsing = false;
|
|
bool _saving = false;
|
|
bool _enhancing = false;
|
|
bool _enhanced = false;
|
|
String? _error;
|
|
List<Trip> _parsedTrips = const [];
|
|
String? _fileName;
|
|
String? _parserType;
|
|
String? _sourceMime;
|
|
|
|
Future<void> _pickAndParse() async {
|
|
if (_parsing) return;
|
|
setState(() {
|
|
_parsing = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
allowMultiple: false,
|
|
withData: true,
|
|
type: FileType.custom,
|
|
allowedExtensions: const ["docx", "pdf"],
|
|
);
|
|
if (result == null || result.files.isEmpty) return;
|
|
final file = result.files.first;
|
|
if (file.bytes == null) {
|
|
throw StateError("Could not read schedule file bytes.");
|
|
}
|
|
final ext = (file.extension ?? "").toLowerCase();
|
|
final parserType = ext == "pdf" ? "stagecoach" : "arriva";
|
|
final sourceMime = ext == "pdf"
|
|
? "application/pdf"
|
|
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
final trips = await _parseTrips(file.bytes!, ext);
|
|
if (trips.isEmpty) {
|
|
throw StateError("No trips parsed from schedule.");
|
|
}
|
|
if (!mounted) return;
|
|
final duties = trips.map((t) => t.dutyNumber).toSet().toList()..sort();
|
|
final firstDuty = duties.isNotEmpty ? duties.first : null;
|
|
|
|
setState(() {
|
|
_parsedTrips = trips;
|
|
_fileName = file.name;
|
|
_parserType = parserType;
|
|
_sourceMime = sourceMime;
|
|
_enhanced = false;
|
|
filterBy = "By Duty";
|
|
if (firstDuty != null) filterValue["By Duty"] = firstDuty;
|
|
});
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsUploadPage] parse failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = error.toString();
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_parsing = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<List<Trip>> _parseTrips(Uint8List bytes, String extension) async {
|
|
if (extension == "pdf") return StagecoachScheduleParser().parseBytes(bytes);
|
|
if (extension == "docx") return ArrivaScheduleParser().parseBytes(bytes);
|
|
throw UnsupportedError("Unsupported schedule extension: $extension");
|
|
}
|
|
|
|
List<Trip> _sortedTrips() {
|
|
final sorted = List<Trip>.from(_parsedTrips);
|
|
sorted.sort((a, b) {
|
|
final aNum = int.tryParse(a.tripNumber);
|
|
final bNum = int.tryParse(b.tripNumber);
|
|
if (aNum != null && bNum != null) {
|
|
final byNumber = aNum.compareTo(bNum);
|
|
if (byNumber != 0) return byNumber;
|
|
} else {
|
|
final byText = a.tripNumber.compareTo(b.tripNumber);
|
|
if (byText != 0) return byText;
|
|
}
|
|
return a.scheduledTime.compareTo(b.scheduledTime);
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
Future<void> _saveToChannel() async {
|
|
if (_saving || _parsedTrips.isEmpty) return;
|
|
final parserType = _parserType;
|
|
final fileName = _fileName;
|
|
final sourceMime = _sourceMime;
|
|
if (parserType == null || fileName == null || sourceMime == null) return;
|
|
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final sortedTrips = _sortedTrips();
|
|
|
|
final tripPayloads = <Map<String, dynamic>>[];
|
|
for (var i = 0; i < sortedTrips.length; i++) {
|
|
final trip = sortedTrips[i];
|
|
|
|
final stationOrder = trip.stationOrder.isEmpty
|
|
? trip.stationTimes.keys.toList()
|
|
: trip.stationOrder;
|
|
|
|
final stops = <Map<String, dynamic>>[];
|
|
for (var s = 0; s < stationOrder.length; s++) {
|
|
final stopName = stationOrder[s].trim();
|
|
if (stopName.isEmpty) continue;
|
|
final scheduled = (trip.stationTimes[stopName] ?? "").trim();
|
|
stops.add({
|
|
"stop_sequence": s + 1,
|
|
"stop_name": stopName,
|
|
"scheduled_time": scheduled.isEmpty ? null : scheduled,
|
|
});
|
|
}
|
|
|
|
tripPayloads.add({
|
|
"trip_number": trip.tripNumber,
|
|
"duty_number": trip.dutyNumber,
|
|
"bus_work_number": trip.busWorkNumber,
|
|
"direction": trip.direction,
|
|
"service_code": trip.tripType,
|
|
"sort_order": i,
|
|
"stops": stops,
|
|
});
|
|
}
|
|
|
|
final bodyJson = {
|
|
"channel_id": widget.channelId,
|
|
"file_name": fileName,
|
|
"source_mime": sourceMime,
|
|
"parser": parserType,
|
|
"trips": tripPayloads,
|
|
};
|
|
|
|
final jsonBytes = utf8.encode(jsonEncode(bodyJson));
|
|
final compressed = GZipCodec().encode(jsonBytes);
|
|
|
|
final response = await _invokeAuthedFunctionRaw(
|
|
"operations-schedule-upload",
|
|
body: Uint8List.fromList(compressed),
|
|
extraHeaders: {
|
|
"Content-Type": "application/json",
|
|
"Content-Encoding": "gzip",
|
|
},
|
|
);
|
|
|
|
final data = response.data;
|
|
if (data is Map && data["error"] != null) {
|
|
throw StateError(data["error"].toString());
|
|
}
|
|
|
|
if (!mounted) return;
|
|
context.go("/channel/${widget.organizationId}/${widget.channelId}");
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsUploadPage] save failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = error.toString();
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_saving = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _enhanceStops() async {
|
|
if (_enhancing || _parsedTrips.isEmpty) return;
|
|
setState(() {
|
|
_enhancing = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final stopNames = _parsedTrips
|
|
.expand((trip) => trip.scheduledStops.map((stop) => stop.name.trim()))
|
|
.where((name) => name.isNotEmpty)
|
|
.toSet()
|
|
.toList(growable: false);
|
|
|
|
if (stopNames.isEmpty) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_enhancing = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
final response = await _invokeAuthedFunction(
|
|
"operations-stop-alias-enhance",
|
|
body: {
|
|
"channel_id": widget.channelId,
|
|
"stop_names": stopNames,
|
|
},
|
|
);
|
|
|
|
final data = response.data;
|
|
if (data is! Map) {
|
|
throw StateError("Enhance function returned unexpected response.");
|
|
}
|
|
final aliasesRaw = data["aliases"];
|
|
if (aliasesRaw is! List) {
|
|
throw StateError("Enhance function returned invalid aliases.");
|
|
}
|
|
|
|
final aliasesByRawStopName = <String, String>{};
|
|
for (final row in aliasesRaw) {
|
|
if (row is! Map) continue;
|
|
final rawStopName = (row["raw_stop_name"] ?? "").toString().trim();
|
|
final aliasStopName = (row["alias_stop_name"] ?? "").toString().trim();
|
|
if (rawStopName.isEmpty || aliasStopName.isEmpty) continue;
|
|
aliasesByRawStopName[rawStopName] = aliasStopName;
|
|
}
|
|
|
|
final enhancedTrips = _parsedTrips
|
|
.map((trip) => trip.withStopAliases(aliasesByRawStopName))
|
|
.toList(growable: false);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_parsedTrips = enhancedTrips;
|
|
_enhanced = true;
|
|
});
|
|
} catch (error, stackTrace) {
|
|
debugPrint("[OperationsUploadPage] enhance failed: $error");
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = error.toString();
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_enhancing = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<dynamic> _invokeAuthedFunctionRaw(
|
|
String functionName, {
|
|
required Uint8List body,
|
|
Map<String, String> extraHeaders = const {},
|
|
}) async {
|
|
var token = await _getFreshAccessToken();
|
|
if (token == null || token.isEmpty) {
|
|
throw StateError("No valid access token available for edge function call.");
|
|
}
|
|
|
|
final client = context.read<SupabaseProvider>().client;
|
|
|
|
Future<dynamic> invokeOnce(String accessToken) {
|
|
client.functions.setAuth(accessToken);
|
|
return client.functions.invoke(
|
|
functionName,
|
|
body: body,
|
|
headers: {
|
|
...extraHeaders,
|
|
"Authorization": "Bearer $accessToken",
|
|
},
|
|
);
|
|
}
|
|
|
|
try {
|
|
return await invokeOnce(token);
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
"[OperationsUploadPage] invokeAuthedFunctionRaw/$functionName failed: $error",
|
|
);
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!_isUnauthorizedFunctionError(error)) rethrow;
|
|
|
|
final refreshed = await client.auth.refreshSession();
|
|
token = refreshed.session?.accessToken ?? client.auth.currentSession?.accessToken;
|
|
if (token == null || token.isEmpty) rethrow;
|
|
return invokeOnce(token);
|
|
}
|
|
}
|
|
|
|
Future<dynamic> _invokeAuthedFunction(
|
|
String functionName, {
|
|
Object? body,
|
|
}) async {
|
|
final client = context.read<SupabaseProvider>().client;
|
|
var token = await _getFreshAccessToken();
|
|
if (token == null || token.isEmpty) {
|
|
throw StateError("No valid access token available for edge function call.");
|
|
}
|
|
|
|
Future<dynamic> invokeOnce(String accessToken) {
|
|
client.functions.setAuth(accessToken);
|
|
return client.functions.invoke(
|
|
functionName,
|
|
body: body,
|
|
headers: <String, String>{"Authorization": "Bearer $accessToken"},
|
|
);
|
|
}
|
|
|
|
try {
|
|
return await invokeOnce(token);
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
"[OperationsUploadPage] invokeAuthedFunction/$functionName initial attempt failed: $error",
|
|
);
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
if (!_isUnauthorizedFunctionError(error)) rethrow;
|
|
|
|
final refreshed = await client.auth.refreshSession();
|
|
token =
|
|
refreshed.session?.accessToken ??
|
|
client.auth.currentSession?.accessToken;
|
|
if (token == null || token.isEmpty) rethrow;
|
|
return invokeOnce(token);
|
|
}
|
|
}
|
|
|
|
Future<String?> _getFreshAccessToken() async {
|
|
final client = context.read<SupabaseProvider>().client;
|
|
var session = client.auth.currentSession;
|
|
final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final expiresAt = session?.expiresAt;
|
|
|
|
final shouldRefresh =
|
|
session != null && expiresAt != null && expiresAt <= nowUnix + 30;
|
|
|
|
if (shouldRefresh) {
|
|
try {
|
|
final refreshed = await client.auth.refreshSession();
|
|
session = refreshed.session ?? client.auth.currentSession;
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
"[OperationsUploadPage] getFreshAccessToken/refreshSession failed: $error",
|
|
);
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
session = client.auth.currentSession;
|
|
}
|
|
}
|
|
|
|
return session?.accessToken;
|
|
}
|
|
|
|
bool _isUnauthorizedFunctionError(Object error) {
|
|
final text = error.toString();
|
|
return text.contains("status: 401") || text.contains("code: 401");
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
|
|
double topPadding = MediaQuery.of(context).padding.top;
|
|
|
|
return Scaffold(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
|
|
Gap(topPadding),
|
|
|
|
SizedBox(
|
|
height: 40,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton.ghost(
|
|
density: ButtonDensity.iconDense,
|
|
icon: const Icon(LucideIcons.arrowLeft),
|
|
onPressed: () => context.go(
|
|
"/channel/${widget.organizationId}/${widget.channelId}",
|
|
),
|
|
),
|
|
Gap(8),
|
|
Text(
|
|
"Operations Schedule Upload",
|
|
).textSmall,
|
|
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const Divider(),
|
|
],
|
|
),
|
|
),
|
|
|
|
Expanded(
|
|
child: _parsedTrips.isEmpty
|
|
? _buildBeforeUpload(context)
|
|
: _buildParsedPreview(context),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBeforeUpload(BuildContext context){
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text("Upload Operations Schedule").h4,
|
|
const Gap(10),
|
|
Button.primary(
|
|
onPressed: _parsing ? null : () => unawaited(_pickAndParse()),
|
|
child: _parsing
|
|
? const Text("Parsing...")
|
|
: const Text("Choose File"),
|
|
),
|
|
if (_error != null) ...[
|
|
const Gap(8),
|
|
Text(
|
|
_error!,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.destructive,
|
|
),
|
|
).small,
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String? filterBy = "By Duty";
|
|
Map<String, Object?> filterValue = {};
|
|
|
|
Trip? _selectedTripForFilter() {
|
|
if (filterBy != "By Trip") return null;
|
|
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
|
|
if (selectedTripNumber.isEmpty) return null;
|
|
for (final trip in _parsedTrips) {
|
|
if (trip.tripNumber == selectedTripNumber) {
|
|
return trip;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<Trip> _selectedDutyTripsForFilter() {
|
|
if (filterBy != "By Duty") return const <Trip>[];
|
|
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
|
|
if (selectedDuty.isEmpty) return const <Trip>[];
|
|
final trips = _parsedTrips
|
|
.where((trip) => trip.dutyNumber == selectedDuty)
|
|
.toList(growable: false);
|
|
final sorted = List<Trip>.from(trips);
|
|
sorted.sort((a, b) {
|
|
final byTime = a.scheduledTime.compareTo(b.scheduledTime);
|
|
if (byTime != 0) return byTime;
|
|
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);
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
String? _selectedStopForFilter() {
|
|
if (filterBy != "By Stop") return null;
|
|
final selectedStop = (filterValue["By Stop"] ?? "").toString().trim();
|
|
if (selectedStop.isEmpty) return null;
|
|
return selectedStop;
|
|
}
|
|
|
|
List<({Trip trip, String? time})> _selectedStopSchedules(String stopName) {
|
|
final rows = <({Trip trip, String? time})>[];
|
|
for (final trip in _parsedTrips) {
|
|
String? scheduledTime;
|
|
for (final stop in trip.scheduledStops) {
|
|
if (stop.name != stopName) continue;
|
|
scheduledTime = stop.scheduledTime;
|
|
break;
|
|
}
|
|
if (scheduledTime == null || scheduledTime.trim().isEmpty) continue;
|
|
rows.add((trip: trip, time: scheduledTime));
|
|
}
|
|
|
|
rows.sort((a, b) {
|
|
final aNum = int.tryParse(a.trip.tripNumber);
|
|
final bNum = int.tryParse(b.trip.tripNumber);
|
|
if (aNum != null && bNum != null) {
|
|
final byNum = aNum.compareTo(bNum);
|
|
if (byNum != 0) return byNum;
|
|
} else {
|
|
final byText = a.trip.tripNumber.compareTo(b.trip.tripNumber);
|
|
if (byText != 0) return byText;
|
|
}
|
|
final byDuty = a.trip.dutyNumber.compareTo(b.trip.dutyNumber);
|
|
if (byDuty != 0) return byDuty;
|
|
return (a.time ?? "").compareTo(b.time ?? "");
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
String? _aliasForStopName(String rawStopName) {
|
|
final normalizedRaw = rawStopName.trim();
|
|
if (normalizedRaw.isEmpty) return null;
|
|
for (final trip in _parsedTrips) {
|
|
for (final stop in trip.scheduledStops) {
|
|
if (stop.name != normalizedRaw) continue;
|
|
final alias = (stop.alias ?? "").trim();
|
|
if (alias.isNotEmpty) return alias;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Widget _stopDisplayLabel(String rawStopName) {
|
|
final alias = _aliasForStopName(rawStopName);
|
|
if (alias == null || alias.isEmpty) return Text(rawStopName);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
|
|
Text(
|
|
alias,
|
|
).semiBold,
|
|
|
|
if (alias != rawStopName)
|
|
Text(
|
|
rawStopName,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
height: 1
|
|
),
|
|
).xSmall.muted.semiBold,
|
|
|
|
],
|
|
);
|
|
}
|
|
|
|
List<TripDiagramEntry> _tripDiagramEntries(Trip trip) {
|
|
final orderedStops = List<ScheduledStop>.from(trip.scheduledStops)
|
|
..sort((a, b) => a.sequence.compareTo(b.sequence));
|
|
return orderedStops
|
|
.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);
|
|
}
|
|
|
|
Widget _buildParsedPreview(BuildContext context) {
|
|
|
|
double bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android;
|
|
|
|
return Column(
|
|
children: [
|
|
|
|
|
|
|
|
Expanded(
|
|
child: Builder(
|
|
builder: (context) {
|
|
final selectedStop = _selectedStopForFilter();
|
|
if (filterBy == "By Stop" && selectedStop != null) {
|
|
final schedules = _selectedStopSchedules(selectedStop);
|
|
final selectedStopAlias = _aliasForStopName(selectedStop);
|
|
return Column(
|
|
children: [
|
|
|
|
Gap(8),
|
|
Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(LucideIcons.pin),
|
|
Gap(8),
|
|
_stopDisplayLabel(selectedStop),
|
|
],
|
|
),
|
|
),
|
|
Divider(),
|
|
Gap(2),
|
|
Divider(),
|
|
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
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.map(
|
|
(row) => Container(
|
|
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: [
|
|
// Text(
|
|
// "${row.trip.dutyNumber} • Trip ${row.trip.tripNumber} • Work ${row.trip.busWorkNumber}",
|
|
// ).extraBold,
|
|
Row(
|
|
children: [
|
|
Text(
|
|
"${row.trip.dutyNumber} • ",
|
|
).extraBold,
|
|
// Gap(),
|
|
Text(
|
|
"Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}",
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
).muted,
|
|
],
|
|
|
|
),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
"${row.trip.scheduledStops.last.displayName}",
|
|
).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(
|
|
// "Trip ${row.trip.tripNumber}",
|
|
// ).textSmall,
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
row.time ?? "--:--",
|
|
).extraBold.mono,
|
|
|
|
Gap(2),
|
|
|
|
Icon(
|
|
LucideIcons.chevronRight
|
|
),
|
|
|
|
Gap(2)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final selectedDutyTrips = _selectedDutyTripsForFilter();
|
|
if (filterBy == "By Duty" && selectedDutyTrips.isNotEmpty) {
|
|
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 ${selectedDutyTrips.first.dutyNumber}".toUpperCase(),
|
|
).extraBold.textSmall,
|
|
],
|
|
),
|
|
),
|
|
Divider(),
|
|
Gap(2),
|
|
Divider(),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (var i = 0; i < selectedDutyTrips.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 ${selectedDutyTrips[i].tripNumber} • Duty ${selectedDutyTrips[i].dutyNumber}",
|
|
).semiBold.textSmall,
|
|
],
|
|
),
|
|
),
|
|
Divider(),
|
|
TripDiagram(
|
|
lineColor: Colors.red,
|
|
entries: _tripDiagramEntries(selectedDutyTrips[i]),
|
|
leftOffset: 12,
|
|
rowHeight: 48,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final selectedTrip = _selectedTripForFilter();
|
|
if (selectedTrip == null) {
|
|
return Center(
|
|
child: Text(
|
|
"Select 'By Trip' or 'By Duty' and choose a value to preview it.",
|
|
).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 ${selectedTrip.tripNumber} • Duty ${selectedTrip.dutyNumber}",
|
|
).semiBold.textSmall,
|
|
|
|
],
|
|
),
|
|
),
|
|
|
|
Divider(),
|
|
|
|
TripDiagram(
|
|
lineColor: Colors.red,
|
|
entries: _tripDiagramEntries(selectedTrip),
|
|
leftOffset: 12,
|
|
rowHeight: 48,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
Divider(),
|
|
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: Select<String>(
|
|
itemBuilder: (context, item) {
|
|
return Text("Filter by: $item");
|
|
},
|
|
popupConstraints: BoxConstraints(
|
|
maxHeight: 300,
|
|
maxWidth: !isMobile ? 200 : double.infinity,
|
|
),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
filterBy = value;
|
|
});
|
|
},
|
|
value: filterBy,
|
|
popup: SelectPopup(
|
|
items: SelectItemList(
|
|
children: [
|
|
SelectItemButton(
|
|
value: "By Duty",
|
|
child: Text(
|
|
"By Duty"
|
|
)
|
|
),
|
|
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"
|
|
);
|
|
|
|
} else if (filterBy == "By Trip") {
|
|
final tripNumber = item.toString();
|
|
final matchingTrip = _parsedTrips.where(
|
|
(trip) => trip.tripNumber == tripNumber,
|
|
);
|
|
if (matchingTrip.isEmpty) return Text("Trip $tripNumber");
|
|
final trip = matchingTrip.first;
|
|
return Text("Trip ${trip.tripNumber} • Duty ${trip.dutyNumber}");
|
|
|
|
} else if (filterBy == "By Stop") {
|
|
return _stopDisplayLabel(item.toString());
|
|
|
|
}
|
|
|
|
return Text("Undefined");
|
|
},
|
|
popupConstraints: BoxConstraints(
|
|
maxHeight: 300,
|
|
maxWidth: !isMobile ? 200 : double.infinity,
|
|
),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
|
|
if (filterBy == null) return;
|
|
|
|
filterValue[filterBy!] = value;
|
|
});
|
|
},
|
|
value: filterValue[filterBy],
|
|
popup: SelectPopup.builder(
|
|
searchPlaceholder: Text(
|
|
"Search ${filterBy?.toLowerCase()}"
|
|
),
|
|
builder: (context, searchQuery) {
|
|
|
|
List<SelectItemButton> items = [];
|
|
|
|
if (filterBy == "By Duty") {
|
|
final duties = _parsedTrips
|
|
.map((t) => t.dutyNumber)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
items = duties.map((d) => SelectItemButton(
|
|
value: d,
|
|
child: Text(d)
|
|
)).toList();
|
|
} else if (filterBy == "By Trip") {
|
|
|
|
final trips = _parsedTrips
|
|
.map((t) => t.tripNumber)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
|
|
// Sort trips by number if possible, otherwise by text
|
|
trips.sort((a, b) {
|
|
final aNum = int.tryParse(a);
|
|
final bNum = int.tryParse(b);
|
|
if (aNum != null && bNum != null) {
|
|
return aNum.compareTo(bNum);
|
|
}
|
|
return a.compareTo(b);
|
|
});
|
|
|
|
items = trips.map((t) => SelectItemButton(
|
|
value: t,
|
|
child: Text("Trip $t")
|
|
)).toList();
|
|
} else if (filterBy == "By Stop") {
|
|
final stops = _parsedTrips
|
|
.expand((t) => t.stationTimes.keys)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
items = stops.map((s) => SelectItemButton(
|
|
value: s,
|
|
child: _stopDisplayLabel(s)
|
|
)).toList();
|
|
}
|
|
|
|
return SelectItemList(
|
|
children: items,
|
|
);
|
|
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
],
|
|
)
|
|
),
|
|
|
|
Divider(),
|
|
|
|
Gap(8),
|
|
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
|
|
Button.secondary(
|
|
trailing: Icon(
|
|
LucideIcons.sparkle
|
|
).iconSmall,
|
|
child: Text(
|
|
_enhanced
|
|
? "Enhanced"
|
|
: _enhancing
|
|
? "Enhancing..."
|
|
: "Enhance"
|
|
),
|
|
onPressed: _enhancing || _enhanced
|
|
? null
|
|
: () => unawaited(_enhanceStops()),
|
|
),
|
|
|
|
Spacer(),
|
|
|
|
Button.secondary(
|
|
trailing: Icon(
|
|
LucideIcons.upload
|
|
).iconSmall,
|
|
child: Text(
|
|
_saving ? "Uploading..." : "Upload"
|
|
),
|
|
onPressed: _saving ? null : () => unawaited(_saveToChannel()),
|
|
)
|
|
|
|
],
|
|
),
|
|
),
|
|
|
|
Gap(max(bottomPadding, 16)),
|
|
|
|
],
|
|
);
|
|
}
|
|
}
|