Add initial project files and configurations for bus_running_record app
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
class ScheduleParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
ScheduleParseException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => "ScheduleParseException: $message";
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
import "package:archive/archive.dart";
|
||||
import "package:excel/excel.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../models/brr_metadata.dart";
|
||||
import "brr_exporter.dart";
|
||||
|
||||
class ArrivaBRRExporter implements BRRExporter {
|
||||
// Numbers xlsx export leaves numFmtId="0" in custom formats which the
|
||||
// excel package rejects. Strip it out before decoding.
|
||||
List<int> _patchTemplateBytes(List<int> bytes) {
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
final output = Archive();
|
||||
|
||||
for (final file in archive) {
|
||||
if (file.name == "xl/styles.xml" && file.isFile) {
|
||||
var xml = utf8.decode(file.content as List<int>);
|
||||
|
||||
// strip the whole numFmts block — Numbers export puts built-in IDs
|
||||
// in there which the excel package rejects
|
||||
xml = xml.replaceAll(RegExp(r'<numFmts[^>]*>.*?</numFmts>', dotAll: true), "");
|
||||
|
||||
// reset all numFmtId refs in xf elements to 0 (General)
|
||||
// so nothing tries to look up the stripped formats
|
||||
xml = xml.replaceAll(RegExp(r'numFmtId="\d+"'), 'numFmtId="0"');
|
||||
|
||||
final patched = utf8.encode(xml);
|
||||
output.addFile(ArchiveFile(file.name, patched.length, patched));
|
||||
} else {
|
||||
output.addFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
return ZipEncoder().encode(output)!;
|
||||
}
|
||||
|
||||
static const int _dataStartRow = 8; // row 9 (0-indexed)
|
||||
static const int _templateDataRows = 15; // rows 9–23
|
||||
|
||||
|
||||
@override
|
||||
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
|
||||
final templateBytes = await rootBundle.load("assets/arriva_brr.xlsx");
|
||||
final patched = _patchTemplateBytes(templateBytes.buffer.asUint8List());
|
||||
final excel = Excel.decodeBytes(patched);
|
||||
|
||||
final sheetName = excel.sheets.keys.first;
|
||||
final sheet = excel[sheetName];
|
||||
|
||||
if (trips.length > _templateDataRows) {
|
||||
_shiftRowsDown(sheet, trips.length - _templateDataRows);
|
||||
}
|
||||
|
||||
_populateData(sheet, trips);
|
||||
|
||||
final bytes = excel.encode();
|
||||
if (bytes == null) throw Exception("Failed to encode Excel");
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
// Shifts all rows from (_dataStartRow + _templateDataRows) onwards down by extraRows
|
||||
void _shiftRowsDown(Sheet sheet, int extraRows) {
|
||||
final firstRowToShift = _dataStartRow + _templateDataRows; // row 24 (index 23)
|
||||
|
||||
// figure out how many rows exist beyond the data block
|
||||
final maxRow = sheet.rows.length;
|
||||
|
||||
// copy from the bottom up to avoid overwriting
|
||||
for (var r = maxRow - 1; r >= firstRowToShift; r--) {
|
||||
final destRow = r + extraRows;
|
||||
if (r >= sheet.rows.length) continue;
|
||||
|
||||
final srcRow = sheet.rows[r];
|
||||
for (var c = 0; c < srcRow.length; c++) {
|
||||
final cell = srcRow[c];
|
||||
if (cell == null) continue;
|
||||
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow)).value =
|
||||
cell.value;
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: destRow)).cellStyle =
|
||||
cell.cellStyle;
|
||||
}
|
||||
}
|
||||
|
||||
// clear the original rows that were shifted
|
||||
for (var r = firstRowToShift; r < firstRowToShift + extraRows; r++) {
|
||||
if (r >= sheet.rows.length) break;
|
||||
for (var c = 0; c < 18; c++) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: r)).value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _populateData(Sheet sheet, List<Trip> trips) {
|
||||
for (var i = 0; i < trips.length; i++) {
|
||||
final trip = trips[i];
|
||||
final row = _dataStartRow + i;
|
||||
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row)).value =
|
||||
TextCellValue(trip.scheduledTime);
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row)).value =
|
||||
TextCellValue(trip.tripNumber);
|
||||
|
||||
if (trip.actualDepartureTime != null) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row)).value =
|
||||
TextCellValue(trip.actualDepartureTime!);
|
||||
}
|
||||
|
||||
if (trip.actualFleetNumber != null) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row)).value =
|
||||
TextCellValue(trip.actualFleetNumber!);
|
||||
}
|
||||
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row)).value =
|
||||
TextCellValue(trip.dutyNumber);
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row)).value =
|
||||
TextCellValue(trip.runningNumber);
|
||||
|
||||
final didOperate = trip.actualDepartureTime != null && trip.actualFleetNumber != null;
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: row)).value =
|
||||
TextCellValue(didOperate ? "Y" : "N");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "dart:typed_data";
|
||||
import "../models/trip.dart";
|
||||
import "../models/brr_metadata.dart";
|
||||
|
||||
abstract class BRRExporter {
|
||||
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import "dart:typed_data";
|
||||
import "package:excel/excel.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../models/brr_metadata.dart";
|
||||
import "brr_exporter.dart";
|
||||
|
||||
class StagecoachBRRExporter implements BRRExporter {
|
||||
|
||||
@override
|
||||
Future<Uint8List> export(List<Trip> trips, BRRMetadata metadata) async {
|
||||
final excel = Excel.createExcel();
|
||||
final sheetName = "BRR";
|
||||
excel.rename(excel.getDefaultSheet()!, sheetName);
|
||||
final sheet = excel[sheetName];
|
||||
|
||||
final headers = [
|
||||
"Dep. Time",
|
||||
"(+/-) No.",
|
||||
"Ser.",
|
||||
"Bus Wk No",
|
||||
"Fleet No.",
|
||||
"Crew Duty",
|
||||
"No.of Pax",
|
||||
"Notes",
|
||||
];
|
||||
|
||||
final bold = CellStyle(bold: true);
|
||||
for (var c = 0; c < headers.length; c++) {
|
||||
final cell = sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: 0));
|
||||
cell.value = TextCellValue(headers[c]);
|
||||
cell.cellStyle = bold;
|
||||
}
|
||||
|
||||
|
||||
for (var i = 0; i < trips.length; i++) {
|
||||
final trip = trips[i];
|
||||
final row = i + 1;
|
||||
|
||||
// Dep Time (HHMM no colon)
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row)).value =
|
||||
TextCellValue(trip.scheduledTime.replaceAll(":", ""));
|
||||
|
||||
// (+/-) No. - user fills in
|
||||
if (trip.actualDepartureTime != null) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row)).value =
|
||||
TextCellValue(trip.actualDepartureTime!);
|
||||
}
|
||||
|
||||
// Ser. — outbound shows route name, inbound shows "PARK"
|
||||
// For now derive from station order: last outbound station as label
|
||||
final ser = trip.direction == "outbound"
|
||||
? (metadata.route != "Unknown" ? metadata.route : "OUT")
|
||||
: "PARK";
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row)).value =
|
||||
TextCellValue(ser);
|
||||
|
||||
// Bus Wk No
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row)).value =
|
||||
TextCellValue(trip.dutyNumber);
|
||||
|
||||
// Fleet No. — actual fleet number entered by user
|
||||
if (trip.actualFleetNumber != null) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row)).value =
|
||||
TextCellValue(trip.actualFleetNumber!);
|
||||
}
|
||||
|
||||
// Crew Duty
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row)).value =
|
||||
TextCellValue(trip.tripNumber);
|
||||
}
|
||||
|
||||
final bytes = excel.encode();
|
||||
if (bytes == null) throw Exception("Failed to encode excel");
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:hive_flutter/hive_flutter.dart";
|
||||
import "pages/home_page.dart";
|
||||
import "pages/station_selection_page.dart";
|
||||
import "pages/trip_list_page.dart";
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Hive.initFlutter();
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
MyApp({super.key});
|
||||
|
||||
final _router = GoRouter(
|
||||
routes: [
|
||||
HomePage.route,
|
||||
StationSelectionPage.route,
|
||||
TripListPage.route,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
title: "Bus Running Record",
|
||||
theme: ThemeData(
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: Color(0xFF00A9CE),
|
||||
onPrimary: Colors.black,
|
||||
surface: Color(0xFF1E1E1E),
|
||||
onSurface: Color(0xFFEEEEEE),
|
||||
surfaceContainerHighest: Color(0xFF2A2A2A),
|
||||
error: Color(0xFFCF6679),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
cardTheme: const CardThemeData(
|
||||
color: Color(0xFF1E1E1E),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
side: BorderSide(color: Color(0xFF2E2E2E)),
|
||||
),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF1A1A1A),
|
||||
foregroundColor: Color(0xFFEEEEEE),
|
||||
elevation: 0,
|
||||
titleTextStyle: TextStyle(
|
||||
color: Color(0xFFEEEEEE),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
borderSide: BorderSide(color: Color(0xFF3A3A3A)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
borderSide: BorderSide(color: Color(0xFF3A3A3A)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
borderSide: BorderSide(color: Color(0xFF00A9CE), width: 1.5),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Color(0xFF252525),
|
||||
labelStyle: TextStyle(color: Color(0xFF999999)),
|
||||
hintStyle: TextStyle(color: Color(0xFF555555)),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00A9CE),
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFF2E2E2E),
|
||||
thickness: 1,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
class BRRMetadata {
|
||||
final String route; // "Metropolitan_Line_Uxbridge"
|
||||
final String date; // "21_09_2025"
|
||||
final String location; // "Uxbridge Station"
|
||||
final String controller; // "Benjamin Watt"
|
||||
final String? sheetNumber;
|
||||
|
||||
BRRMetadata({
|
||||
required this.route,
|
||||
required this.date,
|
||||
required this.location,
|
||||
required this.controller,
|
||||
this.sheetNumber,
|
||||
});
|
||||
|
||||
factory BRRMetadata.fromSchedule(String scheduleText) {
|
||||
// Extract metadata from schedule header
|
||||
final routeMatch = RegExp(r"ROUTE\s+(\w+)").firstMatch(scheduleText);
|
||||
final dateMatch =
|
||||
RegExp(r"(\d{1,2})\s+(\d{1,2})\s+(\d{2})").firstMatch(scheduleText);
|
||||
|
||||
return BRRMetadata(
|
||||
route: routeMatch?.group(1) ?? "Unknown",
|
||||
date: dateMatch != null
|
||||
? "${dateMatch.group(1)}_${dateMatch.group(2)}_20${dateMatch.group(3)}"
|
||||
: DateTime.now().toString().split(" ")[0],
|
||||
location: "Unknown",
|
||||
controller: "Unknown",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"route": route,
|
||||
"date": date,
|
||||
"location": location,
|
||||
"controller": controller,
|
||||
"sheetNumber": sheetNumber,
|
||||
};
|
||||
}
|
||||
|
||||
factory BRRMetadata.fromJson(Map<String, dynamic> json) {
|
||||
return BRRMetadata(
|
||||
route: json["route"] as String,
|
||||
date: json["date"] as String,
|
||||
location: json["location"] as String,
|
||||
controller: json["controller"] as String,
|
||||
sheetNumber: json["sheetNumber"] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import "trip.dart";
|
||||
|
||||
class BRRState {
|
||||
final List<Trip> trips;
|
||||
final String? scheduleFileName;
|
||||
final DateTime? uploadedAt;
|
||||
final String? routeInfo; // e.g., "Metropolitan Line - Uxbridge"
|
||||
final String? serviceDate; // "21/09/2025"
|
||||
|
||||
BRRState({
|
||||
this.trips = const [],
|
||||
this.scheduleFileName,
|
||||
this.uploadedAt,
|
||||
this.routeInfo,
|
||||
this.serviceDate,
|
||||
});
|
||||
|
||||
BRRState copyWith({
|
||||
List<Trip>? trips,
|
||||
String? scheduleFileName,
|
||||
DateTime? uploadedAt,
|
||||
String? routeInfo,
|
||||
String? serviceDate,
|
||||
}) {
|
||||
return BRRState(
|
||||
trips: trips ?? this.trips,
|
||||
scheduleFileName: scheduleFileName ?? this.scheduleFileName,
|
||||
uploadedAt: uploadedAt ?? this.uploadedAt,
|
||||
routeInfo: routeInfo ?? this.routeInfo,
|
||||
serviceDate: serviceDate ?? this.serviceDate,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"trips": trips.map((trip) => trip.toJson()).toList(),
|
||||
"scheduleFileName": scheduleFileName,
|
||||
"uploadedAt": uploadedAt?.toIso8601String(),
|
||||
"routeInfo": routeInfo,
|
||||
"serviceDate": serviceDate,
|
||||
};
|
||||
}
|
||||
|
||||
factory BRRState.fromJson(Map<String, dynamic> json) {
|
||||
return BRRState(
|
||||
trips: (json["trips"] as List?)
|
||||
?.map((tripJson) => Trip.fromJson(tripJson as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
scheduleFileName: json["scheduleFileName"] as String?,
|
||||
uploadedAt: json["uploadedAt"] != null
|
||||
? DateTime.parse(json["uploadedAt"] as String)
|
||||
: null,
|
||||
routeInfo: json["routeInfo"] as String?,
|
||||
serviceDate: json["serviceDate"] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
class Trip {
|
||||
final String scheduledTime; // "15:31" - default departure time (first non-empty time)
|
||||
final String tripNumber; // "112"
|
||||
final String dutyNumber; // "518"
|
||||
final String runningNumber; // "518"
|
||||
final String tripType; // "", "N", "R", "F"
|
||||
final bool isFinishing;
|
||||
final Map<String, String> stationTimes; // {"UXBG": "15:31", "HILL": "15:42", ...}
|
||||
final List<String> stationOrder; // ["UXBG", "UXBG", "HILL", "ICKE", ...]
|
||||
final String direction; // "outbound" or "inbound"
|
||||
|
||||
String? actualDepartureTime; // "15:33" (nullable - user input)
|
||||
String? actualFleetNumber; // "33523" (nullable - user input)
|
||||
|
||||
Trip({
|
||||
required this.scheduledTime,
|
||||
required this.tripNumber,
|
||||
required this.dutyNumber,
|
||||
required this.runningNumber,
|
||||
this.tripType = "",
|
||||
this.isFinishing = false,
|
||||
this.stationTimes = const {},
|
||||
this.stationOrder = const [],
|
||||
this.direction = "outbound",
|
||||
this.actualDepartureTime,
|
||||
this.actualFleetNumber,
|
||||
});
|
||||
|
||||
bool get isComplete =>
|
||||
actualDepartureTime != null && actualFleetNumber != null;
|
||||
|
||||
Trip copyWith({
|
||||
String? scheduledTime,
|
||||
String? tripNumber,
|
||||
String? dutyNumber,
|
||||
String? runningNumber,
|
||||
String? tripType,
|
||||
bool? isFinishing,
|
||||
Map<String, String>? stationTimes,
|
||||
List<String>? stationOrder,
|
||||
String? direction,
|
||||
String? actualDepartureTime,
|
||||
String? actualFleetNumber,
|
||||
}) {
|
||||
return Trip(
|
||||
scheduledTime: scheduledTime ?? this.scheduledTime,
|
||||
tripNumber: tripNumber ?? this.tripNumber,
|
||||
dutyNumber: dutyNumber ?? this.dutyNumber,
|
||||
runningNumber: runningNumber ?? this.runningNumber,
|
||||
tripType: tripType ?? this.tripType,
|
||||
isFinishing: isFinishing ?? this.isFinishing,
|
||||
stationTimes: stationTimes ?? this.stationTimes,
|
||||
stationOrder: stationOrder ?? this.stationOrder,
|
||||
direction: direction ?? this.direction,
|
||||
actualDepartureTime: actualDepartureTime ?? this.actualDepartureTime,
|
||||
actualFleetNumber: actualFleetNumber ?? this.actualFleetNumber,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"scheduledTime": scheduledTime,
|
||||
"tripNumber": tripNumber,
|
||||
"dutyNumber": dutyNumber,
|
||||
"runningNumber": runningNumber,
|
||||
"tripType": tripType,
|
||||
"isFinishing": isFinishing,
|
||||
"stationTimes": stationTimes,
|
||||
"stationOrder": stationOrder,
|
||||
"direction": direction,
|
||||
"actualDepartureTime": actualDepartureTime,
|
||||
"actualFleetNumber": actualFleetNumber,
|
||||
};
|
||||
}
|
||||
|
||||
factory Trip.fromJson(Map<String, dynamic> json) {
|
||||
return Trip(
|
||||
scheduledTime: json["scheduledTime"] as String,
|
||||
tripNumber: json["tripNumber"] as String,
|
||||
dutyNumber: json["dutyNumber"] as String,
|
||||
runningNumber: json["runningNumber"] as String,
|
||||
tripType: json["tripType"] as String? ?? "",
|
||||
isFinishing: json["isFinishing"] as bool? ?? false,
|
||||
stationTimes: Map<String, String>.from(json["stationTimes"] as Map? ?? {}),
|
||||
stationOrder: List<String>.from(json["stationOrder"] as List? ?? []),
|
||||
direction: json["direction"] as String? ?? "outbound",
|
||||
actualDepartureTime: json["actualDepartureTime"] as String?,
|
||||
actualFleetNumber: json["actualFleetNumber"] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../parsers/arriva_schedule_parser.dart";
|
||||
import "../parsers/stagecoach_schedule_parser.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
import "../services/storage_service.dart";
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/",
|
||||
builder: (context, state) => const HomePage(),
|
||||
);
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
bool _isUploading = false;
|
||||
String? _errorMessage;
|
||||
BRROperator _selectedOperator = BRROperator.arriva;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restoreSession();
|
||||
}
|
||||
|
||||
Future<void> _restoreSession() async {
|
||||
final state = await StorageService().loadState();
|
||||
if (state != null && state.trips.isNotEmpty && mounted) {
|
||||
context.go("/trips", extra: {
|
||||
"trips": state.trips,
|
||||
"fileName": state.scheduleFileName ?? "",
|
||||
"operator": _selectedOperator,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String get _fileExtension {
|
||||
switch (_selectedOperator) {
|
||||
case BRROperator.arriva:
|
||||
return "docx";
|
||||
case BRROperator.stagecoach:
|
||||
return "pdf";
|
||||
}
|
||||
}
|
||||
|
||||
String get _formatLabel {
|
||||
switch (_selectedOperator) {
|
||||
case BRROperator.arriva:
|
||||
return "Arriva schedule format (.docx)";
|
||||
case BRROperator.stagecoach:
|
||||
return "Stagecoach schedule format (.pdf)";
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadSchedule() async {
|
||||
print("Upload button pressed");
|
||||
setState(() {
|
||||
_isUploading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [_fileExtension],
|
||||
allowMultiple: false,
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
setState(() { _isUploading = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) throw Exception("Could not read file bytes");
|
||||
|
||||
String? routeName;
|
||||
final List<Trip> trips;
|
||||
if (_selectedOperator == BRROperator.stagecoach) {
|
||||
final parser = StagecoachScheduleParser();
|
||||
trips = await parser.parseBytes(file.bytes!);
|
||||
routeName = parser.parsedRouteName;
|
||||
} else {
|
||||
trips = await ArrivaScheduleParser().parseBytes(file.bytes!);
|
||||
}
|
||||
|
||||
if (trips.isEmpty) throw Exception("No trips found in schedule");
|
||||
|
||||
if (mounted) {
|
||||
context.go("/station-selection", extra: {
|
||||
"trips": trips,
|
||||
"fileName": file.name,
|
||||
"operator": _selectedOperator,
|
||||
if (routeName != null) "routeName": routeName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error: $e");
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() { _isUploading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
const Text(
|
||||
"BUS\nRUNNING\nRECORD",
|
||||
style: TextStyle(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.0,
|
||||
letterSpacing: -1,
|
||||
color: Color(0xFFEEEEEE),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Container(
|
||||
width: 40,
|
||||
height: 3,
|
||||
color: const Color(0xFF00A9CE),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// operator selector
|
||||
const Text(
|
||||
"OPERATOR",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF777777),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
_OperatorChip(
|
||||
label: "ARRIVA",
|
||||
selected: _selectedOperator == BRROperator.arriva,
|
||||
onTap: () => setState(() => _selectedOperator = BRROperator.arriva),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_OperatorChip(
|
||||
label: "STAGECOACH",
|
||||
selected: _selectedOperator == BRROperator.stagecoach,
|
||||
onTap: () => setState(() => _selectedOperator = BRROperator.stagecoach),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// format info
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF2E2E2E)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 16, color: Color(0xFF777777)),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_formatLabel,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF777777)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A1515),
|
||||
border: Border.all(color: const Color(0xFF7A2020)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCF6679),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isUploading ? null : _uploadSchedule,
|
||||
child: _isUploading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: const Text("LOAD SCHEDULE"),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OperatorChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OperatorChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF002A33) : const Color(0xFF1E1E1E),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF2E2E2E),
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF777777),
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
|
||||
class StationSelectionPage extends StatefulWidget {
|
||||
final List<Trip> trips;
|
||||
final String fileName;
|
||||
final BRROperator operator;
|
||||
final String? routeName;
|
||||
|
||||
const StationSelectionPage({
|
||||
super.key,
|
||||
required this.trips,
|
||||
required this.fileName,
|
||||
this.operator = BRROperator.arriva,
|
||||
this.routeName,
|
||||
});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/station-selection",
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
if (extra == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go("/");
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return StationSelectionPage(
|
||||
trips: (extra["trips"] as List).cast<Trip>(),
|
||||
fileName: extra["fileName"] as String,
|
||||
operator: extra["operator"] as BRROperator? ?? BRROperator.arriva,
|
||||
routeName: extra["routeName"] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
State<StationSelectionPage> createState() => _StationSelectionPageState();
|
||||
}
|
||||
|
||||
class _StationSelectionPageState extends State<StationSelectionPage> {
|
||||
String? _selectedStation;
|
||||
String? _selectedDirection;
|
||||
List<String> _availableStations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_extractStations();
|
||||
}
|
||||
|
||||
void _extractStations() {
|
||||
final stationsSet = <String>{};
|
||||
for (final trip in widget.trips) {
|
||||
stationsSet.addAll(trip.stationOrder);
|
||||
}
|
||||
_availableStations = stationsSet.toList()..sort();
|
||||
}
|
||||
|
||||
bool _isTerminus(String station) {
|
||||
for (final trip in widget.trips) {
|
||||
if (trip.stationOrder.isEmpty) continue;
|
||||
if (trip.stationOrder.first == station || trip.stationOrder.last == station) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// For a given station, find where buses are headed in each direction
|
||||
String? _getTerminus(String station, String direction) {
|
||||
for (final trip in widget.trips) {
|
||||
if (!trip.stationOrder.contains(station)) continue;
|
||||
if (trip.stationOrder.isEmpty) continue;
|
||||
|
||||
// "departing" = outbound trips, show where they end up
|
||||
if (direction == "departing" && trip.direction == "outbound") {
|
||||
return trip.stationOrder.last;
|
||||
}
|
||||
|
||||
// "arriving" = inbound trips, show where they end up (opposite terminus)
|
||||
if (direction == "arriving" && trip.direction == "inbound") {
|
||||
return trip.stationOrder.last;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getDirectionLabel(String direction) {
|
||||
if (_selectedStation == null) {
|
||||
return direction == "departing" ? "DEPARTING" : "ARRIVING";
|
||||
}
|
||||
|
||||
if (_isTerminus(_selectedStation!)) {
|
||||
return direction == "departing" ? "DEPARTURES" : "ARRIVALS";
|
||||
}
|
||||
|
||||
final terminus = _getTerminus(_selectedStation!, direction);
|
||||
return "towards\n${terminus ?? "..."}";
|
||||
}
|
||||
|
||||
void _onContinue() {
|
||||
if (_selectedStation == null || _selectedDirection == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Select both a station and direction")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final filteredTrips = _filterAndUpdateTrips();
|
||||
|
||||
if (filteredTrips.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("No trips found for that selection")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.go("/trips", extra: {
|
||||
"trips": filteredTrips,
|
||||
"fileName": widget.fileName,
|
||||
"station": _selectedStation,
|
||||
"direction": _selectedDirection,
|
||||
"operator": widget.operator,
|
||||
if (widget.routeName != null) "routeName": widget.routeName,
|
||||
});
|
||||
}
|
||||
|
||||
List<Trip> _filterAndUpdateTrips() {
|
||||
final filtered = <Trip>[];
|
||||
final isTerminusStation = _isTerminus(_selectedStation!);
|
||||
|
||||
for (final trip in widget.trips) {
|
||||
if (!trip.stationTimes.containsKey(_selectedStation)) continue;
|
||||
|
||||
if (_selectedDirection != "both") {
|
||||
// For terminus stations, the direction logic flips:
|
||||
// "departing" from a terminus that's first on inbound = show inbound trips
|
||||
// "arriving" at a terminus thats last on outbound = show outbound trips
|
||||
final isFirstStation = trip.stationOrder.isNotEmpty && trip.stationOrder.first == _selectedStation;
|
||||
final isLastStation = trip.stationOrder.isNotEmpty && trip.stationOrder.last == _selectedStation;
|
||||
|
||||
if (isTerminusStation) {
|
||||
if (_selectedDirection == "departing" && !isFirstStation) continue;
|
||||
if (_selectedDirection == "arriving" && !isLastStation) continue;
|
||||
} else {
|
||||
if (_selectedDirection == "departing" && trip.direction != "outbound") continue;
|
||||
if (_selectedDirection == "arriving" && trip.direction != "inbound") continue;
|
||||
}
|
||||
}
|
||||
|
||||
final stationTime = trip.stationTimes[_selectedStation];
|
||||
if (stationTime != null) {
|
||||
filtered.add(trip.copyWith(scheduledTime: stationTime));
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go("/"),
|
||||
),
|
||||
title: const Text("SELECT STATION"),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// file info strip
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.description_outlined, size: 14, color: Color(0xFF777777)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.fileName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF777777),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${widget.trips.length} trips",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF00A9CE),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
const Text(
|
||||
"STATION",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF777777),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedStation,
|
||||
dropdownColor: const Color(0xFF252525),
|
||||
style: const TextStyle(color: Color(0xFFEEEEEE), fontSize: 15),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Choose station...",
|
||||
),
|
||||
items: _availableStations.map((station) {
|
||||
return DropdownMenuItem(
|
||||
value: station,
|
||||
child: Text(station),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) => setState(() => _selectedStation = value),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text(
|
||||
"DIRECTION",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF777777),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
_DirButton(
|
||||
label: _getDirectionLabel("departing"),
|
||||
selected: _selectedDirection == "departing",
|
||||
onTap: () => setState(() => _selectedDirection = "departing"),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_DirButton(
|
||||
label: _getDirectionLabel("arriving"),
|
||||
selected: _selectedDirection == "arriving",
|
||||
onTap: () => setState(() => _selectedDirection = "arriving"),
|
||||
),
|
||||
if (widget.operator == BRROperator.stagecoach) ...[
|
||||
const SizedBox(width: 10),
|
||||
_DirButton(
|
||||
label: "BOTH",
|
||||
selected: _selectedDirection == "both",
|
||||
onTap: () => setState(() => _selectedDirection = "both"),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _onContinue,
|
||||
child: const Text("VIEW TRIPS"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirButton extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DirButton({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF002A33) : const Color(0xFF1E1E1E),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF2E2E2E),
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF777777),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:file_saver/file_saver.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:share_plus/share_plus.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../models/brr_metadata.dart";
|
||||
import "../models/brr_state.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
import "../services/storage_service.dart";
|
||||
|
||||
class TripListPage extends StatefulWidget {
|
||||
final List<Trip> trips;
|
||||
final String fileName;
|
||||
final bool fromStationSelection;
|
||||
final BRROperator operator;
|
||||
final String? routeName;
|
||||
|
||||
const TripListPage({
|
||||
super.key,
|
||||
required this.trips,
|
||||
required this.fileName,
|
||||
this.fromStationSelection = false,
|
||||
this.operator = BRROperator.arriva,
|
||||
this.routeName,
|
||||
});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/trips",
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
if (extra == null) {
|
||||
// extra is lost on hot reload — home will restore from storage
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go("/");
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return TripListPage(
|
||||
trips: (extra["trips"] as List).cast<Trip>(),
|
||||
fileName: extra["fileName"] as String,
|
||||
fromStationSelection: extra.containsKey("station"),
|
||||
operator: extra["operator"] as BRROperator? ?? BRROperator.arriva,
|
||||
routeName: extra["routeName"] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
State<TripListPage> createState() => _TripListPageState();
|
||||
}
|
||||
|
||||
class _TripListPageState extends State<TripListPage> {
|
||||
late List<Trip> _trips;
|
||||
bool _isExporting = false;
|
||||
String? _expandedTripId;
|
||||
final _storageService = StorageService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_trips = List.from(widget.trips);
|
||||
if (widget.operator == BRROperator.arriva) {
|
||||
_trips.sort((a, b) => int.tryParse(a.tripNumber)?.compareTo(int.tryParse(b.tripNumber) ?? 0) ?? a.tripNumber.compareTo(b.tripNumber));
|
||||
} else {
|
||||
final firstMins = _toMins(_trips.first.scheduledTime);
|
||||
_trips.sort((a, b) => _sortableTime(a.scheduledTime, firstMins).compareTo(_sortableTime(b.scheduledTime, firstMins)));
|
||||
}
|
||||
_autoSave();
|
||||
}
|
||||
|
||||
int _toMins(String time) {
|
||||
final parts = time.split(":");
|
||||
if (parts.length != 2) return 0;
|
||||
return (int.tryParse(parts[0]) ?? 0) * 60 + (int.tryParse(parts[1]) ?? 0);
|
||||
}
|
||||
|
||||
// Anything earlier than the first trip's time is treated as next-day.
|
||||
int _sortableTime(String time, int thresholdMins) {
|
||||
final mins = _toMins(time);
|
||||
return mins < thresholdMins ? mins + 1440 : mins;
|
||||
}
|
||||
|
||||
Future<void> _autoSave() async {
|
||||
final state = BRRState(
|
||||
trips: _trips,
|
||||
scheduleFileName: widget.fileName,
|
||||
uploadedAt: DateTime.now(),
|
||||
);
|
||||
await _storageService.saveState(state);
|
||||
}
|
||||
|
||||
int get _completedCount => _trips.where((t) => t.isComplete).length;
|
||||
|
||||
String get _exportFileName =>
|
||||
"BRR_${DateTime.now().toString().split(" ")[0].replaceAll("-", "_")}.xlsx";
|
||||
|
||||
Future<void> _handleExport() async {
|
||||
setState(() => _isExporting = true);
|
||||
|
||||
try {
|
||||
final metadata = BRRMetadata(
|
||||
route: widget.routeName ?? "Unknown",
|
||||
date: DateTime.now().toString().split(" ")[0].replaceAll("-", "_"),
|
||||
location: "Unknown",
|
||||
controller: "Unknown",
|
||||
);
|
||||
|
||||
final exportService = BRRExportService(operator: widget.operator);
|
||||
final result = await exportService.exportBRR(_trips, metadata);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.isSuccess) {
|
||||
await _saveFile(result.bytes!, _exportFileName);
|
||||
} else {
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Cannot Export"),
|
||||
content: Text(result.errors.join("\n")),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("OK"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isExporting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveFile(Uint8List bytes, String fileName) async {
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
final tmp = await getTemporaryDirectory();
|
||||
final file = File("${tmp.path}/$fileName");
|
||||
await file.writeAsBytes(bytes);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: "BRR ${DateTime.now().toString().split(" ")[0]}",
|
||||
);
|
||||
} else {
|
||||
await FileSaver.instance.saveFile(
|
||||
name: fileName.replaceAll(".xlsx", ""),
|
||||
bytes: bytes,
|
||||
ext: "xlsx",
|
||||
mimeType: MimeType.microsoftExcel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateTrip(int index, Trip updatedTrip) {
|
||||
setState(() {
|
||||
_trips[index] = updatedTrip;
|
||||
});
|
||||
_autoSave();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = _trips.isEmpty ? 0.0 : _completedCount / _trips.length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () async {
|
||||
await _storageService.clearState();
|
||||
if (!mounted) return;
|
||||
if (widget.fromStationSelection) {
|
||||
context.go("/station-selection", extra: {
|
||||
"trips": widget.trips,
|
||||
"fileName": widget.fileName,
|
||||
});
|
||||
} else {
|
||||
context.go("/");
|
||||
}
|
||||
},
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("TRIPS"),
|
||||
Text(
|
||||
widget.fileName,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF777777),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: TextButton(
|
||||
onPressed: _isExporting ? null : _handleExport,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00A9CE),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
child: _isExporting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black),
|
||||
)
|
||||
: const Text("EXPORT"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// progress bar
|
||||
Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"$_completedCount / ${_trips.length}",
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFEEEEEE),
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${(progress * 100).toStringAsFixed(0)}% complete",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF777777),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: const Color(0xFF2E2E2E),
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF00A9CE)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _trips.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final trip = _trips[index];
|
||||
final tripId = "${trip.tripNumber}_$index";
|
||||
final isExpanded = _expandedTripId == tripId;
|
||||
|
||||
// find the next trip this duty runs after this one
|
||||
final nextDutyTrip = _trips
|
||||
.skip(index + 1)
|
||||
.where((t) => t.dutyNumber == trip.dutyNumber)
|
||||
.firstOrNull;
|
||||
|
||||
return TripCard(
|
||||
trip: trip,
|
||||
nextDutyTrip: nextDutyTrip,
|
||||
isExpanded: isExpanded,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_expandedTripId = isExpanded ? null : tripId;
|
||||
});
|
||||
},
|
||||
onUpdate: (updatedTrip) => _updateTrip(index, updatedTrip),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TripCard extends StatefulWidget {
|
||||
final Trip trip;
|
||||
final Trip? nextDutyTrip;
|
||||
final bool isExpanded;
|
||||
final VoidCallback onTap;
|
||||
final Function(Trip) onUpdate;
|
||||
|
||||
const TripCard({
|
||||
super.key,
|
||||
required this.trip,
|
||||
this.nextDutyTrip,
|
||||
required this.isExpanded,
|
||||
required this.onTap,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TripCard> createState() => _TripCardState();
|
||||
}
|
||||
|
||||
class _TripCardState extends State<TripCard> {
|
||||
late TextEditingController _actualTimeController;
|
||||
late TextEditingController _actualFleetController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actualTimeController = TextEditingController(text: widget.trip.actualDepartureTime ?? "");
|
||||
_actualFleetController = TextEditingController(text: widget.trip.actualFleetNumber ?? "");
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_actualTimeController.dispose();
|
||||
_actualFleetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _saveChanges() {
|
||||
final updated = widget.trip.copyWith(
|
||||
actualDepartureTime: _actualTimeController.text.isEmpty ? null : _actualTimeController.text,
|
||||
actualFleetNumber: _actualFleetController.text.isEmpty ? null : _actualFleetController.text,
|
||||
);
|
||||
widget.onUpdate(updated);
|
||||
}
|
||||
|
||||
void _setSameAsScheduled() {
|
||||
_actualTimeController.text = widget.trip.scheduledTime;
|
||||
_saveChanges();
|
||||
}
|
||||
|
||||
void _setNow() {
|
||||
final now = DateTime.now();
|
||||
_actualTimeController.text =
|
||||
"${now.hour.toString().padLeft(2, "0")}:${now.minute.toString().padLeft(2, "0")}";
|
||||
_saveChanges();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isComplete = widget.trip.isComplete;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: isComplete ? const Color(0xFF4CAF50) : const Color(0xFF2E2E2E),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
// collapsed row
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// time
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
widget.trip.scheduledTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontFamily: "monospace",
|
||||
color: Color(0xFFEEEEEE),
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF252525),
|
||||
border: Border.all(color: const Color(0xFF2E2E2E)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
"Trip ${widget.trip.tripNumber}",
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFCCCCCC),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.trip.isFinishing)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
color: const Color(0xFF2A1A00),
|
||||
child: const Text(
|
||||
"FIN",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF00A9CE),
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Icon(
|
||||
widget.isExpanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 18,
|
||||
color: const Color(0xFF555555),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// expanded section
|
||||
if (widget.isExpanded) ...[
|
||||
Container(height: 1, color: const Color(0xFF2E2E2E)),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// scheduled info grid
|
||||
Row(
|
||||
children: [
|
||||
_InfoCell("DUTY", widget.trip.dutyNumber),
|
||||
const SizedBox(width: 12),
|
||||
_InfoCell("RUNNING", widget.trip.runningNumber),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// actual departure time
|
||||
const Text(
|
||||
"ACTUAL TIME",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF666666),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: TextField(
|
||||
controller: _actualTimeController,
|
||||
style: const TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: "HH:MM",
|
||||
suffixIcon: _actualTimeController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
_actualTimeController.clear();
|
||||
_saveChanges();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) { setState(() {}); _saveChanges(); },
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton(
|
||||
onPressed: _setNow,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF999999),
|
||||
side: const BorderSide(color: Color(0xFF3A3A3A)),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: const Text("NOW"),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton(
|
||||
onPressed: _setSameAsScheduled,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF999999),
|
||||
side: const BorderSide(color: Color(0xFF3A3A3A)),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: const Text("AS SCHED"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// actual fleet
|
||||
const Text(
|
||||
"ACTUAL FLEET",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF666666),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _actualFleetController,
|
||||
style: const TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "EC###",
|
||||
),
|
||||
onChanged: (_) => _saveChanges(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Container(height: 1, color: const Color(0xFF2E2E2E)),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const Text(
|
||||
"SCHEDULE",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF666666),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_ScheduleDiagram(
|
||||
stations: widget.trip.stationOrder,
|
||||
stationTimes: widget.trip.stationTimes,
|
||||
nextDutyTrip: widget.nextDutyTrip,
|
||||
leftOffset: 16,
|
||||
),
|
||||
|
||||
Container(height: 1, color: const Color(0xFF2E2E2E)),
|
||||
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScheduleDiagram extends StatelessWidget {
|
||||
final List<String> stations;
|
||||
final Map<String, String> stationTimes;
|
||||
final Trip? nextDutyTrip;
|
||||
final double leftOffset;
|
||||
|
||||
const _ScheduleDiagram({required this.stations, required this.stationTimes, this.nextDutyTrip, this.leftOffset = 0});
|
||||
|
||||
static const double rowHeight = 32;
|
||||
static const double lineW = 7;
|
||||
static const double dotSize = 15;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lineColor = Theme.of(context).colorScheme.primary;
|
||||
final placeholderLabel = nextDutyTrip != null
|
||||
? "BECOMES: TRIP ${nextDutyTrip!.tripNumber}"
|
||||
: "END OF DUTY";
|
||||
final placeholderTime = nextDutyTrip?.scheduledTime;
|
||||
// deduplicate stops with the same name + time anywhere in the list
|
||||
final deduped = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final s in stations) {
|
||||
final key = "$s|${stationTimes[s]}";
|
||||
if (seen.contains(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.add(s);
|
||||
}
|
||||
|
||||
final displayStations = [...deduped, placeholderLabel];
|
||||
final displayTimes = Map<String, String>.from(stationTimes);
|
||||
if (placeholderTime != null) displayTimes[placeholderLabel] = placeholderTime;
|
||||
final totalHeight = displayStations.length * rowHeight;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// the painted line + dots behind the labels
|
||||
SizedBox(
|
||||
height: totalHeight,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _LineDiagramPainter(
|
||||
count: displayStations.length,
|
||||
rowHeight: rowHeight,
|
||||
lineW: lineW,
|
||||
dotSize: dotSize,
|
||||
lineColor: lineColor,
|
||||
leftOffset: leftOffset,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// labels column on top
|
||||
Positioned.fill(child: Column(
|
||||
children: List.generate(displayStations.length, (i) {
|
||||
final station = displayStations[i];
|
||||
final time = displayTimes[station];
|
||||
|
||||
return Container(
|
||||
height: rowHeight,
|
||||
color: i == displayStations.length - 1
|
||||
? lineColor.withValues(alpha: 0.12)
|
||||
: i.isOdd ? Colors.white.withValues(alpha: 0.03) : null,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: leftOffset + 26, right: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
station,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFFDDDDDD),
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
time ?? "--:--",
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
fontWeight: FontWeight.w700,
|
||||
color: time != null
|
||||
? const Color(0xFFEEEEEE)
|
||||
: const Color(0xFF555555),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineDiagramPainter extends CustomPainter {
|
||||
final int count;
|
||||
final double rowHeight;
|
||||
final double lineW;
|
||||
final double dotSize;
|
||||
final Color lineColor;
|
||||
|
||||
// metro_map_maker constants, scaled ~3.5x (world units → screen px, base line = 6px world)
|
||||
static const double _linePad = 3.0;
|
||||
static const double _blobPad = 4.0;
|
||||
static const double _centerSize = 4.0;
|
||||
static const double _cornerFactor = 0.3;
|
||||
|
||||
final double leftOffset;
|
||||
|
||||
_LineDiagramPainter({
|
||||
required this.count,
|
||||
required this.rowHeight,
|
||||
required this.lineW,
|
||||
required this.dotSize,
|
||||
required this.lineColor,
|
||||
this.leftOffset = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// derived — mirrors metro_map_maker getStationRingGeometry
|
||||
final strokeW = lineW / 2.0; // baseStrokeWidth
|
||||
final ringSize = _centerSize + strokeW; // kStationCenterSize + strokeW
|
||||
final paddingWidth = strokeW + _blobPad; // kStationBlobPaddingWidth + strokeW
|
||||
|
||||
// cx: enough left margin for the station blob outline, plus caller offset
|
||||
final cx = leftOffset + ringSize / 2 + paddingWidth / 2 + 2;
|
||||
|
||||
final firstY = rowHeight / 2;
|
||||
final lastY = (count - 1) * rowHeight + rowHeight / 2;
|
||||
|
||||
// build rrects once for reuse
|
||||
final rrects = List.generate(count, (i) {
|
||||
final cy = i * rowHeight + rowHeight / 2;
|
||||
final rect = Rect.fromCenter(center: Offset(cx, cy), width: ringSize, height: ringSize);
|
||||
return RRect.fromRectAndRadius(rect, Radius.circular(ringSize * _cornerFactor));
|
||||
});
|
||||
|
||||
// ── pass 1: station white padding (under everything) ─────────────
|
||||
for (final rrect in rrects) {
|
||||
canvas.drawRRect(
|
||||
rrect,
|
||||
Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = paddingWidth
|
||||
..style = PaintingStyle.stroke,
|
||||
);
|
||||
}
|
||||
|
||||
// ── pass 2: line white padding ────────────────────────────────────
|
||||
canvas.drawLine(
|
||||
Offset(cx, firstY),
|
||||
Offset(cx, lastY),
|
||||
Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = lineW + _linePad
|
||||
..strokeCap = StrokeCap.round
|
||||
..style = PaintingStyle.stroke,
|
||||
);
|
||||
|
||||
// ── pass 3: colored line ──────────────────────────────────────────
|
||||
canvas.drawLine(
|
||||
Offset(cx, firstY),
|
||||
Offset(cx, lastY),
|
||||
Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = lineW
|
||||
..strokeCap = StrokeCap.round
|
||||
..style = PaintingStyle.stroke,
|
||||
);
|
||||
|
||||
// ── pass 4: station white fill + colored ring (on top of line) ────
|
||||
for (final rrect in rrects) {
|
||||
final centerRRect = rrect.deflate(strokeW / 2);
|
||||
|
||||
canvas.drawRRect(centerRRect, Paint()..color = Colors.white..style = PaintingStyle.fill);
|
||||
|
||||
canvas.drawRRect(
|
||||
rrect,
|
||||
Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = strokeW
|
||||
..style = PaintingStyle.stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_LineDiagramPainter old) =>
|
||||
old.count != count || old.lineColor != lineColor || old.lineW != lineW || old.leftOffset != leftOffset;
|
||||
}
|
||||
|
||||
class _InfoCell extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoCell(this.label, this.value);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
color: const Color(0xFF252525),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF666666),
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFCCCCCC),
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import "dart:typed_data";
|
||||
import "package:docx_to_text/docx_to_text.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../exceptions/schedule_parse_exception.dart";
|
||||
import "schedule_parser.dart";
|
||||
|
||||
class ArrivaScheduleParser implements ScheduleParser {
|
||||
// OUTBOUND FORMAT: HHMM_HHMM times... EC### duty running trip
|
||||
// Times appear on the LEFT, trip info on the RIGHT
|
||||
static final _outboundPattern = RegExp(
|
||||
r"^(\d{4})_(\d{4})\s+(.*?)\s+(EC\d+)\s+(\d+)\s+([NRF]?)(\d+)\s+(\d+)",
|
||||
);
|
||||
|
||||
static final _outboundFinishingPattern = RegExp(
|
||||
r"^(\d{4})_(\d{4})\s+(.*?)\s+(EC\d+)\s+(\d+)\s+F",
|
||||
);
|
||||
|
||||
// INBOUND FORMAT: trip duty running EC### ... HHMM_HHMM times...
|
||||
// Trip info appears on the LEFT, times on the RIGHT
|
||||
// Note: running number may have N/R/F prefix like "N503" or be separate like "N 503"
|
||||
static final _inboundPattern = RegExp(
|
||||
r"^(\d+)\s+(\d+)\s+([NRF]?)(\d+)\s+(EC\d+)\s+.*?(\d{4})_(\d{4})\s+([\d\s]+)",
|
||||
);
|
||||
|
||||
static final _inboundFinishingPattern = RegExp(
|
||||
r"^(\d+)\s+(\d+)\s+F\s+(EC\d+)\s+.*?(\d{4})_(\d{4})(.*)",
|
||||
);
|
||||
|
||||
@override
|
||||
bool canParse(String content) {
|
||||
// Check if content looks like Arriva format
|
||||
return content.contains("EC5") &&
|
||||
content.contains("FINISH AT POINT") &&
|
||||
RegExp(r"\d{4}_\d{4}").hasMatch(content);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Trip>> parseBytes(Uint8List bytes) async {
|
||||
// Step 1: Extract text
|
||||
final text = _extractTextFromDocx(bytes);
|
||||
|
||||
// DEBUG: Print extracted text
|
||||
print("=== EXTRACTED TEXT START ===");
|
||||
print(text);
|
||||
print("=== EXTRACTED TEXT END ===");
|
||||
|
||||
// Step 2: Parse document structure (headers and trips)
|
||||
final lines = text.split("\n");
|
||||
final documentSections = _parseDocumentSections(lines);
|
||||
|
||||
print("=== FOUND ${documentSections.length} SECTIONS ===");
|
||||
for (var section in documentSections) {
|
||||
print("Section: ${section.direction}, ${section.stations.length} stations, ${section.tripLines.length} trips");
|
||||
}
|
||||
|
||||
if (documentSections.isEmpty) {
|
||||
throw ScheduleParseException("No trip data found in schedule");
|
||||
}
|
||||
|
||||
// Step 3: Parse trips from all sections
|
||||
final trips = <Trip>[];
|
||||
for (var section in documentSections) {
|
||||
final sectionTrips = _parseSectionTrips(section);
|
||||
trips.addAll(sectionTrips);
|
||||
print("✓ Parsed ${sectionTrips.length} trips from ${section.direction} section");
|
||||
}
|
||||
|
||||
// Step 4: Sort by scheduled time
|
||||
trips.sort((a, b) => a.scheduledTime.compareTo(b.scheduledTime));
|
||||
|
||||
return trips;
|
||||
}
|
||||
|
||||
String _extractTextFromDocx(Uint8List bytes) {
|
||||
try {
|
||||
return docxToText(bytes);
|
||||
} catch (e) {
|
||||
throw ScheduleParseException("Failed to read document: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String _formatTime(String rawTime) {
|
||||
if (rawTime.length != 4) {
|
||||
throw FormatException("Invalid time format: $rawTime");
|
||||
}
|
||||
return "${rawTime.substring(0, 2)}:${rawTime.substring(2, 4)}";
|
||||
}
|
||||
|
||||
List<_DocumentSection> _parseDocumentSections(List<String> lines) {
|
||||
final sections = <_DocumentSection>[];
|
||||
_DocumentSection? currentSection;
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i].trim();
|
||||
|
||||
// Check if this is a station header line
|
||||
final stations = _extractStationHeader(line);
|
||||
if (stations != null && stations.isNotEmpty) {
|
||||
// Save previous section if it exists
|
||||
if (currentSection != null && currentSection.tripLines.isNotEmpty) {
|
||||
sections.add(currentSection);
|
||||
}
|
||||
|
||||
// Direction is determined later from the first trip line
|
||||
currentSection = _DocumentSection(
|
||||
stations: stations,
|
||||
direction: "unknown",
|
||||
tripLines: [],
|
||||
);
|
||||
|
||||
print("Found station header at line $i with ${stations.length} stations");
|
||||
print(" Stations: ${stations.take(3).join(", ")} ... ${stations.skip(stations.length - 2).join(", ")}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a trip line
|
||||
if (currentSection != null && _isTripLine(line)) {
|
||||
|
||||
// Infer direction from first trip line if not yet set
|
||||
if (currentSection.direction == "unknown") {
|
||||
currentSection.direction = _inferDirectionFromTripLine(line);
|
||||
print(" Direction inferred: ${currentSection.direction}");
|
||||
}
|
||||
|
||||
currentSection.tripLines.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Add final section
|
||||
if (currentSection != null && currentSection.tripLines.isNotEmpty) {
|
||||
sections.add(currentSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
List<String>? _extractStationHeader(String line) {
|
||||
// Station headers have multiple short uppercase codes, no digits, no underscores
|
||||
if (line.contains(RegExp(r"\d")) ||
|
||||
line.contains("EC") ||
|
||||
line.contains("_") ||
|
||||
line.length < 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Split by whitespace and filter for potential station codes (3-8 uppercase letters)
|
||||
final parts = line.split(RegExp(r"\s+"));
|
||||
final potentialStations = parts
|
||||
.where((part) => part.length >= 3 &&
|
||||
part.length <= 8 &&
|
||||
RegExp(r"^[A-Z]+$").hasMatch(part))
|
||||
.toList();
|
||||
|
||||
if (potentialStations.length < 8) return null;
|
||||
|
||||
// Filter out common metadata words that appear in headers
|
||||
const nonStationWords = {
|
||||
"TRP", "DUTY", "BUS", "START", "END", "GAR", "DEP", "ARR",
|
||||
"DENOTES", "FINISHES", "RELIEF", "TRIP", "NEXT", "NO", "AT",
|
||||
"SPELL", "HOURS", "TOTAL", "LAYOVER", "MILES", "LIVE", "DEAD",
|
||||
"MILEAGE", "TIME", "SIGN", "FORM", "NXT", "THIS", "HAS", "OR",
|
||||
"FOR", "CHANGE", "SERVICE", "POINT", "LSN", "MAN", "RUI", "SN",
|
||||
"ROUTE", "RUNNING", "PREV", "FIN", "ENTOD", "SOALL", "USHRS",
|
||||
"ADDTL", "CASH", "TODAYS", "REL", "IEF",
|
||||
};
|
||||
|
||||
final stations = potentialStations
|
||||
.where((s) => !nonStationWords.contains(s))
|
||||
.toList();
|
||||
|
||||
// Need at least 5 actual station-like codes remaining - the structural
|
||||
// density of codes is what marks this as a station header, not a known list
|
||||
return stations.length >= 5 ? stations : null;
|
||||
}
|
||||
|
||||
// Determine direction from the format of the first trip line.
|
||||
// Lines starting with digits are outbound (trip number comes first).
|
||||
// Lines starting with underscores or a bare time are inbound (times come first).
|
||||
String _inferDirectionFromTripLine(String line) {
|
||||
if (RegExp(r"^\d{4}_\d{4}").hasMatch(line)) return "inbound";
|
||||
if (RegExp(r"^_+\d{4}").hasMatch(line)) return "inbound";
|
||||
if (RegExp(r"^\d+\s+\d+").hasMatch(line)) return "outbound";
|
||||
return "outbound";
|
||||
}
|
||||
|
||||
bool _isTripLine(String line) {
|
||||
return RegExp(r"\d{4}_\d{4}").hasMatch(line) && line.contains("EC");
|
||||
}
|
||||
|
||||
List<Trip> _parseSectionTrips(_DocumentSection section) {
|
||||
final trips = <Trip>[];
|
||||
|
||||
for (final line in section.tripLines) {
|
||||
Trip? trip;
|
||||
|
||||
// Detect actual line format by looking at structure
|
||||
// Inbound: starts with numbers (trip duty running EC###) or (trip duty F EC###)
|
||||
// Note: running number might be "N503 EC" (with spaces) or "N 503 EC" or just "503 EC"
|
||||
// Outbound: starts with HHMM_HHMM
|
||||
final isOutboundFormat = RegExp(r"^\d{4}_\d{4}").hasMatch(line);
|
||||
final isInboundFormat = RegExp(r"^\d+\s+\d+\s+(?:[NRF]\d+\s+|[NRF]\s+\d+\s+|F\s+|\d+\s+)EC").hasMatch(line);
|
||||
|
||||
if (isOutboundFormat) {
|
||||
trip = _parseOutboundTrip(line, section.stations);
|
||||
} else if (isInboundFormat) {
|
||||
trip = _parseInboundTrip(line, section.stations);
|
||||
}
|
||||
|
||||
if (trip != null) {
|
||||
trips.add(trip);
|
||||
} else {
|
||||
final format = isOutboundFormat ? "outbound" : isInboundFormat ? "inbound" : "unknown";
|
||||
print("Failed to parse $format line: ${line.substring(0, line.length > 80 ? 80 : line.length)}...");
|
||||
}
|
||||
}
|
||||
|
||||
return trips;
|
||||
}
|
||||
|
||||
Trip? _parseInboundTrip(
|
||||
String line,
|
||||
List<String> stations,
|
||||
) {
|
||||
// INBOUND: trip duty running EC### ... HHMM_HHMM times...
|
||||
var match = _inboundPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final tripNumber = match.group(1)!;
|
||||
final dutyNumber = match.group(2)!;
|
||||
final tripType = match.group(3) ?? "";
|
||||
final runningNumber = match.group(4)!;
|
||||
final firstTime = match.group(6)!;
|
||||
final secondTime = match.group(7)!;
|
||||
final timesString = match.group(8) ?? "";
|
||||
|
||||
// Build complete time array: first_time, second_time, then remaining times
|
||||
final times = [firstTime, secondTime];
|
||||
final additionalTimes = _extractTimesFromString(timesString);
|
||||
times.addAll(additionalTimes);
|
||||
|
||||
final stationTimes = _mapStationsToTimes(stations, times);
|
||||
final scheduledTime = _formatTime(firstTime);
|
||||
|
||||
return Trip(
|
||||
tripNumber: tripNumber,
|
||||
dutyNumber: dutyNumber,
|
||||
runningNumber: runningNumber,
|
||||
scheduledTime: scheduledTime,
|
||||
tripType: tripType,
|
||||
isFinishing: false,
|
||||
stationTimes: stationTimes,
|
||||
stationOrder: stations,
|
||||
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
|
||||
);
|
||||
}
|
||||
|
||||
// Try finishing pattern
|
||||
match = _inboundFinishingPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final tripNumber = match.group(1)!;
|
||||
final dutyNumber = match.group(2)!;
|
||||
final firstTime = match.group(4)!;
|
||||
final secondTime = match.group(5)!;
|
||||
final timesString = match.group(6) ?? "";
|
||||
|
||||
final times = [firstTime, secondTime];
|
||||
final additionalTimes = _extractTimesFromString(timesString);
|
||||
times.addAll(additionalTimes);
|
||||
|
||||
final stationTimes = _mapStationsToTimes(stations, times);
|
||||
final scheduledTime = _formatTime(firstTime);
|
||||
|
||||
return Trip(
|
||||
tripNumber: tripNumber,
|
||||
dutyNumber: dutyNumber,
|
||||
runningNumber: dutyNumber,
|
||||
scheduledTime: scheduledTime,
|
||||
tripType: "F",
|
||||
isFinishing: true,
|
||||
stationTimes: stationTimes,
|
||||
stationOrder: stations,
|
||||
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Trip? _parseOutboundTrip(
|
||||
String line,
|
||||
List<String> stations,
|
||||
) {
|
||||
// OUTBOUND: HHMM_HHMM times... EC### duty running trip
|
||||
var match = _outboundPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final firstTime = match.group(1)!;
|
||||
final secondTime = match.group(2)!;
|
||||
final timesString = match.group(3) ?? "";
|
||||
final dutyNumber = match.group(5)!;
|
||||
final tripType = match.group(6) ?? "";
|
||||
final runningNumber = match.group(7)!;
|
||||
final tripNumber = match.group(8)!;
|
||||
|
||||
// Build complete time array: first_time, second_time, then remaining times
|
||||
final times = [firstTime, secondTime];
|
||||
times.addAll(_extractTimesFromString(timesString));
|
||||
|
||||
final stationTimes = _mapStationsToTimes(stations, times);
|
||||
final scheduledTime = _formatTime(firstTime);
|
||||
|
||||
return Trip(
|
||||
tripNumber: tripNumber,
|
||||
dutyNumber: dutyNumber,
|
||||
runningNumber: runningNumber,
|
||||
scheduledTime: scheduledTime,
|
||||
tripType: tripType,
|
||||
isFinishing: false,
|
||||
stationTimes: stationTimes,
|
||||
stationOrder: stations,
|
||||
direction: (int.tryParse(tripNumber) ?? 0).isOdd ? "inbound" : "outbound",
|
||||
);
|
||||
}
|
||||
|
||||
// Try finishing pattern
|
||||
match = _outboundFinishingPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final firstTime = match.group(1)!;
|
||||
final secondTime = match.group(2)!;
|
||||
final timesString = match.group(3) ?? "";
|
||||
final dutyNumber = match.group(5)!;
|
||||
|
||||
final times = [firstTime, secondTime];
|
||||
times.addAll(_extractTimesFromString(timesString));
|
||||
|
||||
final stationTimes = _mapStationsToTimes(stations, times);
|
||||
final scheduledTime = _formatTime(firstTime);
|
||||
|
||||
return Trip(
|
||||
tripNumber: dutyNumber, // Finishing trips may not have separate trip number
|
||||
dutyNumber: dutyNumber,
|
||||
runningNumber: dutyNumber,
|
||||
scheduledTime: scheduledTime,
|
||||
tripType: "F",
|
||||
isFinishing: true,
|
||||
stationTimes: stationTimes,
|
||||
stationOrder: stations,
|
||||
direction: (int.tryParse(dutyNumber) ?? 0).isOdd ? "inbound" : "outbound",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _extractTimesFromString(String timesString) {
|
||||
// Extract all 4-digit times from the string
|
||||
final pattern = RegExp(r"\b(\d{4})\b");
|
||||
return pattern
|
||||
.allMatches(timesString)
|
||||
.map((m) => m.group(1)!)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> _extractAllTimes(String line) {
|
||||
// Extract all 4-digit times, including those in HHMM_HHMM format
|
||||
final timePattern = RegExp(r"\b(\d{4})(?:_(\d{4}))?\b");
|
||||
final matches = timePattern.allMatches(line);
|
||||
final times = <String>[];
|
||||
|
||||
for (final match in matches) {
|
||||
// Add first time
|
||||
times.add(match.group(1)!);
|
||||
// Add second time if it exists (from HHMM_HHMM)
|
||||
if (match.group(2) != null) {
|
||||
times.add(match.group(2)!);
|
||||
}
|
||||
}
|
||||
|
||||
return times;
|
||||
}
|
||||
|
||||
Map<String, String> _mapStationsToTimes(
|
||||
List<String> stations,
|
||||
List<String> times,
|
||||
) {
|
||||
final stationTimes = <String, String>{};
|
||||
|
||||
for (var i = 0; i < stations.length && i < times.length; i++) {
|
||||
final time = times[i];
|
||||
// Only add non-empty times (not "____" or similar)
|
||||
if (RegExp(r"^\d{4}$").hasMatch(time)) {
|
||||
stationTimes[stations[i]] = _formatTime(time);
|
||||
}
|
||||
}
|
||||
|
||||
return stationTimes;
|
||||
}
|
||||
}
|
||||
|
||||
class _DocumentSection {
|
||||
final List<String> stations;
|
||||
String direction;
|
||||
final List<String> tripLines;
|
||||
|
||||
_DocumentSection({
|
||||
required this.stations,
|
||||
required this.direction,
|
||||
required this.tripLines,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "dart:typed_data";
|
||||
import "../models/trip.dart";
|
||||
|
||||
abstract class ScheduleParser {
|
||||
Future<List<Trip>> parseBytes(Uint8List bytes);
|
||||
bool canParse(String content);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import "dart:typed_data";
|
||||
import "package:syncfusion_flutter_pdf/pdf.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../exceptions/schedule_parse_exception.dart";
|
||||
import "schedule_parser.dart";
|
||||
|
||||
class StagecoachScheduleParser implements ScheduleParser {
|
||||
String? parsedRouteName;
|
||||
|
||||
@override
|
||||
bool canParse(String content) {
|
||||
final collapsed = content.replaceAll("\n", " ");
|
||||
return collapsed.contains("ROUTE") &&
|
||||
RegExp(r"\dD\d{3}").hasMatch(collapsed);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Trip>> parseBytes(Uint8List bytes) async {
|
||||
final rawPages = _extractPages(bytes);
|
||||
|
||||
// Syncfusion splits text with newlines everywhere.
|
||||
// Collapse each page into single line, normalize all whitespace runs to single space
|
||||
final pages = rawPages.map((p) =>
|
||||
p.replaceAll("\n", " ").replaceAll(RegExp(r"\s+"), " ").trim()
|
||||
).toList();
|
||||
|
||||
print("=== STAGECOACH: ${pages.length} pages ===");
|
||||
|
||||
// Extract route name — "ROUTE 099CSATURDAY..." or "ROUTE CRL SATURDAY..."
|
||||
final routeMatch = RegExp(r"ROUTE\s+(\w+?)(?:SATURDAY|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FRIDAY)", caseSensitive: false).firstMatch(pages.first);
|
||||
if (routeMatch != null) {
|
||||
parsedRouteName = routeMatch.group(1);
|
||||
} else {
|
||||
final fallback = RegExp(r"ROUTE\s+(\w+)").firstMatch(pages.first);
|
||||
parsedRouteName = fallback?.group(1);
|
||||
}
|
||||
print("Route: $parsedRouteName");
|
||||
|
||||
final allTrips = <Trip>[];
|
||||
final seenKeys = <String>{};
|
||||
|
||||
for (var pi = 0; pi < pages.length; pi++) {
|
||||
final page = pages[pi];
|
||||
|
||||
// Direction: outbound pages have "DEPT GAR" in header
|
||||
final isOutbound = RegExp(r"DEPT\s+GAR").hasMatch(page);
|
||||
final direction = isOutbound ? "outbound" : "inbound";
|
||||
|
||||
final stations = _extractStations(page, isOutbound);
|
||||
print("Page ${pi + 1}: $direction, stations: $stations");
|
||||
|
||||
final trips = _parseTripsFromPage(page, direction, stations);
|
||||
print(" Trips found: ${trips.length}");
|
||||
|
||||
for (final t in trips) {
|
||||
final key = "${t.tripNumber}_${t.scheduledTime}_${t.direction}";
|
||||
if (seenKeys.contains(key)) continue;
|
||||
seenKeys.add(key);
|
||||
allTrips.add(t);
|
||||
print(" TRIP: ${t.tripNumber} | ${t.scheduledTime} | ${t.direction} | duty=${t.dutyNumber} run=${t.runningNumber} | stations=${t.stationTimes}");
|
||||
}
|
||||
}
|
||||
|
||||
if (allTrips.isEmpty) {
|
||||
throw ScheduleParseException("No trips found in stagecoach schedule");
|
||||
}
|
||||
|
||||
// keep original parse order for stagecoach
|
||||
print("Total unique trips: ${allTrips.length}");
|
||||
return allTrips;
|
||||
}
|
||||
|
||||
List<String> _extractPages(Uint8List bytes) {
|
||||
try {
|
||||
final doc = PdfDocument(inputBytes: bytes);
|
||||
final pages = <String>[];
|
||||
for (int i = 0; i < doc.pages.count; i++) {
|
||||
pages.add(PdfTextExtractor(doc).extractText(
|
||||
startPageIndex: i, endPageIndex: i,
|
||||
));
|
||||
}
|
||||
doc.dispose();
|
||||
return pages;
|
||||
} catch (e) {
|
||||
throw ScheduleParseException("Failed to read PDF: $e");
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _extractStations(String pageText, bool isOutbound) {
|
||||
// After full normalization (all whitespace = single space), the header looks like:
|
||||
// "...RLF STS: DEPT GAR STFC BS T STFC BS FGAT PA ... ROMF SN T RLF DEPT TIME..."
|
||||
// or inbound: "...RLF STS: ROMF SN T ROMF SN ... STFC BS T ARR GAR RLF DEPT TIME..."
|
||||
//
|
||||
// Station names are 4-letter codes followed by qualifiers: "STFC BS T", "MNPK BY" etc.
|
||||
// In the syncfusion text each station name part was on its own line, so after
|
||||
// collapsing they alternate: CODE QUALIFIER CODE QUALIFIER...
|
||||
|
||||
String? stationBlock;
|
||||
|
||||
if (isOutbound) {
|
||||
// Between "DEPT GAR" and "RLF DEPT TIME" (or just before the first duty ID)
|
||||
final match = RegExp(r"DEPT GAR (.+?) RLF DEPT TIME").firstMatch(pageText);
|
||||
if (match != null) stationBlock = match.group(1);
|
||||
} else {
|
||||
// Between "RLF STS:" and "ARR GAR"
|
||||
final match = RegExp(r"RLF STS: (.+?) ARR GAR").firstMatch(pageText);
|
||||
if (match != null) stationBlock = match.group(1);
|
||||
}
|
||||
|
||||
if (stationBlock == null) {
|
||||
print(" No station header match");
|
||||
return _extractStationsFallback(pageText);
|
||||
}
|
||||
|
||||
return _pairStationTokens(stationBlock.trim());
|
||||
}
|
||||
|
||||
List<String> _extractStationsFallback(String pageText) {
|
||||
// broader: between STS: and first duty ID
|
||||
final match = RegExp(r"STS: (.+?) \d[A-Z]\d{3}").firstMatch(pageText);
|
||||
if (match == null) return [];
|
||||
|
||||
var block = match.group(1)!;
|
||||
// Remove known non-station words
|
||||
for (final word in ["DEPT GAR", "ARR GAR", "RLF DEPT TIME", "DUTY END",
|
||||
"NEXT BUS", "NEXT TRIP", "DRV SPELL", "RLF"]) {
|
||||
block = block.replaceAll(word, " ");
|
||||
}
|
||||
return _pairStationTokens(block.trim());
|
||||
}
|
||||
|
||||
List<String> _pairStationTokens(String block) {
|
||||
// Station names in stagecoach PDFs are structured as:
|
||||
// 4-letter CODE followed by a qualifier (1-3 chars, possibly with T suffix)
|
||||
// After normalization: "STFC BS T STFC BS FGAT PA MNPK BY ILFD BS"
|
||||
//
|
||||
// The pattern is: each station starts with a 3-4 uppercase letter code,
|
||||
// followed by qualifier tokens until the next 3-4 letter code.
|
||||
//
|
||||
// Strategy: scan tokens, when we see a 3-4 letter all-caps token that looks
|
||||
// like a station code, start a new station name. Append subsequent tokens
|
||||
// as qualifiers until the next station code.
|
||||
|
||||
final tokens = block.split(" ").where((t) => t.isNotEmpty).toList();
|
||||
final stations = <String>[];
|
||||
var current = <String>[];
|
||||
|
||||
for (final token in tokens) {
|
||||
// A new station code: 3-4 uppercase letters
|
||||
if (RegExp(r"^[A-Z]{3,4}$").hasMatch(token) && current.isNotEmpty) {
|
||||
stations.add(current.join(" "));
|
||||
current = [token];
|
||||
} else if (current.isEmpty && RegExp(r"^[A-Z]{3,4}$").hasMatch(token)) {
|
||||
current = [token];
|
||||
} else {
|
||||
current.add(token);
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty) {
|
||||
stations.add(current.join(" "));
|
||||
}
|
||||
|
||||
return stations;
|
||||
}
|
||||
|
||||
|
||||
List<Trip> _parseTripsFromPage(String pageText, String direction, List<String> stations) {
|
||||
final trips = <Trip>[];
|
||||
|
||||
// Find all trip-start positions: DUTY_ID followed by RUN(digits) and BUS(3 digits)
|
||||
// This pattern marks the beginning of a real trip entry
|
||||
final tripStartPattern = RegExp(r"\b((\d/)?\d[A-Z]\d{3})\s+(\d+)\s+(\d{3})\b");
|
||||
final starts = tripStartPattern.allMatches(pageText).toList();
|
||||
|
||||
print(" Trip-start matches: ${starts.length}");
|
||||
|
||||
int parsed = 0;
|
||||
int failed = 0;
|
||||
|
||||
for (var i = 0; i < starts.length; i++) {
|
||||
final segStart = starts[i].start;
|
||||
// segment ends where the next trip starts, or at end of text
|
||||
final segEnd = (i + 1 < starts.length) ? starts[i + 1].start : pageText.length;
|
||||
final segment = pageText.substring(segStart, segEnd).trim();
|
||||
|
||||
final dutyId = starts[i].group(1)!;
|
||||
final runNumber = starts[i].group(3)!;
|
||||
final busWorkingNo = starts[i].group(4)!;
|
||||
|
||||
final trip = _parseTripFromSegment(segment, dutyId, runNumber, busWorkingNo, direction, stations);
|
||||
if (trip != null) {
|
||||
trips.add(trip);
|
||||
parsed++;
|
||||
} else {
|
||||
failed++;
|
||||
final preview = segment.length > 120 ? segment.substring(0, 120) : segment;
|
||||
print(" Skip: $preview");
|
||||
}
|
||||
}
|
||||
|
||||
print(" Parsed: $parsed, skipped: $failed");
|
||||
|
||||
return trips;
|
||||
}
|
||||
|
||||
Trip? _parseTripFromSegment(
|
||||
String segment, String dutyId, String runNumber, String busWorkingNo,
|
||||
String direction, List<String> stations,
|
||||
) {
|
||||
// Skip header junk
|
||||
if (segment.contains("TRIP NO") ||
|
||||
segment.contains("DUTY START") || segment.contains("SCHEDULE DATE")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip deadhead/light runs — but only if LIGHT appears early in the segment
|
||||
// (before station times), not in trailing text from the next concatenated entry
|
||||
final lightMatch = RegExp(r"LIGHT", caseSensitive: false).firstMatch(segment);
|
||||
if (lightMatch != null) {
|
||||
final headerEnd = RegExp(RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}").firstMatch(segment)?.end ?? 0;
|
||||
// if LIGHT is within 40 chars of the header, its a genuine deadhead
|
||||
if (lightMatch.start - headerEnd < 40) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the leading "DUTY RUN BUS" we already extracted
|
||||
final afterHeader = segment.substring(
|
||||
RegExp(RegExp.escape(dutyId) + r"\s+\d+\s+\d{3}").firstMatch(segment)!.end
|
||||
).trim();
|
||||
|
||||
// Strip relief/garage prefix to get to the times
|
||||
var timesSection = afterHeader;
|
||||
// remove leading relief markers and garage name
|
||||
timesSection = timesSection.replaceFirst(RegExp(r"^(RR|FR|FRX|FX|R)\s*"), "");
|
||||
timesSection = timesSection.replaceFirst(RegExp(r"^HomeGara\s*"), "");
|
||||
// Also remove leading text from inbound like just "R " or "F "
|
||||
timesSection = timesSection.replaceFirst(RegExp(r"^(F|R)\s+"), "");
|
||||
|
||||
// Extract all 4-digit time values
|
||||
final times = RegExp(r"\b(\d{4})\b")
|
||||
.allMatches(timesSection)
|
||||
.map((m) => m.group(1)!)
|
||||
.where((t) {
|
||||
final h = int.tryParse(t.substring(0, 2)) ?? 99;
|
||||
return h < 30;
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (times.isEmpty) return null;
|
||||
|
||||
// For garage trips, first time is the garage departure — skip it
|
||||
final isFromGarage = afterHeader.contains("HomeGara");
|
||||
int firstStationIdx = 0;
|
||||
if (isFromGarage && times.length > 1) {
|
||||
firstStationIdx = 1;
|
||||
}
|
||||
|
||||
// Only use the first N times (where N = station count) to avoid
|
||||
// grabbing times from the trailing metadata (spell, next bus etc)
|
||||
final stationTimes = <String, String>{};
|
||||
for (var i = 0; i < stations.length; i++) {
|
||||
final timeIdx = firstStationIdx + i;
|
||||
if (timeIdx < times.length) {
|
||||
stationTimes[stations[i]] = _formatTime(times[timeIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
if (stationTimes.isEmpty) return null;
|
||||
|
||||
final scheduledTime = _formatTime(times[firstStationIdx]);
|
||||
|
||||
String tripType = "";
|
||||
if (afterHeader.startsWith("RR") || afterHeader.startsWith("R")) tripType = "R";
|
||||
|
||||
final actualDirection = dutyId.startsWith("2/") ? "inbound" : direction;
|
||||
|
||||
// If trip direction doesnt match page direction,
|
||||
// reverse station order so terminus is correct
|
||||
final tripStations = actualDirection != direction
|
||||
? stations.reversed.toList()
|
||||
: List<String>.from(stations);
|
||||
|
||||
return Trip(
|
||||
scheduledTime: scheduledTime,
|
||||
tripNumber: dutyId,
|
||||
dutyNumber: busWorkingNo,
|
||||
runningNumber: runNumber,
|
||||
tripType: tripType,
|
||||
isFinishing: segment.contains("BUS FIN"),
|
||||
stationTimes: stationTimes,
|
||||
stationOrder: tripStations,
|
||||
direction: actualDirection,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(String rawTime) {
|
||||
if (rawTime.length != 4) return rawTime;
|
||||
return "${rawTime.substring(0, 2)}:${rawTime.substring(2, 4)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "dart:typed_data";
|
||||
import "../models/trip.dart";
|
||||
import "../models/brr_metadata.dart";
|
||||
import "../exporters/brr_exporter.dart";
|
||||
import "../exporters/arriva_brr_exporter.dart";
|
||||
import "../exporters/stagecoach_brr_exporter.dart";
|
||||
|
||||
enum BRROperator { arriva, stagecoach }
|
||||
|
||||
class ExportResult {
|
||||
final Uint8List? bytes;
|
||||
final List<String> errors;
|
||||
bool get isSuccess => errors.isEmpty;
|
||||
|
||||
ExportResult.success(this.bytes) : errors = [];
|
||||
ExportResult.error(this.errors) : bytes = null;
|
||||
}
|
||||
|
||||
class BRRExportService {
|
||||
final BRROperator operator;
|
||||
|
||||
BRRExportService({this.operator = BRROperator.arriva});
|
||||
|
||||
BRRExporter get _exporter {
|
||||
switch (operator) {
|
||||
case BRROperator.arriva:
|
||||
return ArrivaBRRExporter();
|
||||
case BRROperator.stagecoach:
|
||||
return StagecoachBRRExporter();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ExportResult> exportBRR(
|
||||
List<Trip> trips,
|
||||
BRRMetadata metadata,
|
||||
) async {
|
||||
try {
|
||||
final bytes = await _exporter.export(trips, metadata);
|
||||
return ExportResult.success(bytes);
|
||||
} catch (e) {
|
||||
return ExportResult.error(["Export failed: $e"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import "dart:convert";
|
||||
import "package:hive_flutter/hive_flutter.dart";
|
||||
import "../models/brr_state.dart";
|
||||
|
||||
class StorageService {
|
||||
static const _boxName = "brr_box";
|
||||
static const _stateKey = "brr_state";
|
||||
|
||||
Future<Box<String>> _openBox() => Hive.openBox<String>(_boxName);
|
||||
|
||||
Future<void> saveState(BRRState state) async {
|
||||
final box = await _openBox();
|
||||
await box.put(_stateKey, jsonEncode(state.toJson()));
|
||||
}
|
||||
|
||||
Future<BRRState?> loadState() async {
|
||||
final box = await _openBox();
|
||||
final jsonString = box.get(_stateKey);
|
||||
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
return BRRState.fromJson(jsonDecode(jsonString) as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearState() async {
|
||||
final box = await _openBox();
|
||||
await box.delete(_stateKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "../models/trip.dart";
|
||||
|
||||
class TripValidator {
|
||||
static List<String> validate(Trip trip) {
|
||||
final errors = <String>[];
|
||||
|
||||
// Validate time format
|
||||
if (!RegExp(r"^\d{2}:\d{2}$").hasMatch(trip.scheduledTime)) {
|
||||
errors.add("Invalid time format: ${trip.scheduledTime}");
|
||||
}
|
||||
|
||||
// Validate trip number
|
||||
if (trip.tripNumber.isEmpty) {
|
||||
errors.add("Missing trip number");
|
||||
}
|
||||
|
||||
// Validate duty/running numbers
|
||||
if (trip.dutyNumber.isEmpty || trip.runningNumber.isEmpty) {
|
||||
errors.add("Missing duty or running number");
|
||||
}
|
||||
|
||||
// Validate actual departure time if provided
|
||||
if (trip.actualDepartureTime != null &&
|
||||
!RegExp(r"^\d{2}:\d{2}$").hasMatch(trip.actualDepartureTime!)) {
|
||||
errors.add("Invalid actual departure time format: ${trip.actualDepartureTime}");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static bool isValid(Trip trip) {
|
||||
return validate(trip).isEmpty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user