Add initial project files and configurations for bus_running_record app

This commit is contained in:
ImBenji
2026-03-25 17:19:53 +00:00
parent f5da563c29
commit e41e14e252
151 changed files with 9829 additions and 0 deletions
@@ -0,0 +1,8 @@
class ScheduleParseException implements Exception {
final String message;
ScheduleParseException(this.message);
@override
String toString() => "ScheduleParseException: $message";
}
+127
View File
@@ -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 923
@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");
}
}
}
+7
View File
@@ -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
View File
@@ -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,
),
);
}
}
+51
View File
@@ -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?,
);
}
}
+58
View File
@@ -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?,
);
}
}
+91
View File
@@ -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?,
);
}
}
+287
View File
@@ -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,
),
),
),
),
),
);
}
}
+334
View File
@@ -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,
),
),
),
),
);
}
}
+871
View File
@@ -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,
),
],
),
),
);
}
}
+409
View File
@@ -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,
});
}
+7
View File
@@ -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);
}
+301
View File
@@ -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)}";
}
}
+44
View File
@@ -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"]);
}
}
}
+33
View File
@@ -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);
}
}
+34
View File
@@ -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;
}
}