36 KiB
Rail Replacement BRR App - MVP Specification
Overview
A Flutter mobile app for rail replacement controllers to generate and fill Bus Running Reports (BRRs) from schedule documents. Eliminates manual data entry by parsing schedule files and providing a streamlined interface for completing required fields.
Note: MVP is being developed and tested using Arriva schedule format, but the app should be designed to support multiple Train Operating Companies (TOCs) in future iterations.
Core Problem
Currently, controllers must manually transcribe trip data from schedule documents into BRR spreadsheets - a time-consuming, error-prone process that can involve 40-50+ trips per shift.
MVP Features
1. Schedule Upload & Parsing
Functionality:
- Upload schedule documents (.docx)
- Parse schedule to extract:
- Trip numbers
- Duty numbers
- Running numbers
- Bus fleet numbers (format varies by TOC)
- Scheduled departure times
- Route information
Technical Requirements:
- File picker for local document selection
- Docx parsing library (e.g.,
docx_to_textor native parser) - Regex pattern matching for schedule format
- Parser should be modular to support multiple TOC formats in future
- Error handling for invalid/unparseable files
MVP Implementation:
- Initial parser built for Arriva schedule format
- Architecture allows for additional parsers to be added later
User Flow:
- User taps "Upload Schedule"
- Select .docx file from device
- App parses and displays trip count
- User confirms or re-uploads if incorrect
Schedule Parsing Implementation
Understanding Arriva Schedule Format
Document Structure
Arriva schedules are .docx files with a specific text-based layout:
- Header Section - Route information and metadata
- Column Headers - Station/timing point names
- Trip Lines - Individual service data
- Duty Scheduling Section - Bus allocation information
Example Trip Line (Outbound from Uxbridge)
1531_1532 1540 1542 1545 1549 1552 1557 1604 1613 1618 1624 1624 EC518 518 518 112 1635 115
Breaking this down:
1531_1532- Departure window (1531 = actual departure from Uxbridge)1540 1542 1545...- Timing points along route1618 1624 1624- Arrival times at destinationEC518- Bus fleet number518- Duty number518- Running number112- Trip number (current)1635- Next scheduled time115- Next trip number
Example Trip Line (Finishing Trip)
1521_1522 1530 1532 1535 1539 1542 1547 1554 1603 1608 1614______FINISH AT POINT EC509 509 F 110
Key differences:
______FINISH AT POINT- Indicates service terminatesFflag instead of running number110- Trip number (no next trip)
Parsing Strategy
Step 1: Extract Raw Text from DOCX
import 'package:docx_to_text/docx_to_text.dart';
Future<String> extractTextFromDocx(String filePath) async {
try {
final bytes = await File(filePath).readAsBytes();
final text = docxToText(bytes);
return text;
} catch (e) {
throw ScheduleParseException('Failed to read document: $e');
}
}
Step 2: Identify Schedule Section
The schedule contains multiple sections. We need the section with trip data, which:
- Contains lines starting with time patterns (HHMM_HHMM)
- Has bus fleet numbers (EC###)
- Contains trip numbers
List<String> extractTripLines(String fullText) {
final lines = fullText.split('\n');
final tripLines = <String>[];
for (final line in lines) {
// Look for lines that start with time pattern and contain EC bus numbers
if (RegExp(r'^\d{4}_\d{4}').hasMatch(line) && line.contains('EC')) {
tripLines.add(line.trim());
}
}
return tripLines;
}
Step 3: Parse Individual Trip Lines
Pattern 1: Regular Trips
RegExp regularTripPattern = RegExp(
r'^(\d{4})_(\d{4})\s+.*\s+(EC\d+)\s+(\d+)\s+([NRF]?)(\d+)\s+(\d+)\s+(\d{4})\s+(\d+)'
);
Trip? parseRegularTrip(String line) {
final match = regularTripPattern.firstMatch(line);
if (match == null) return null;
return Trip(
scheduledTime: formatTime(match.group(1)!), // 1531 -> "15:31"
tripNumber: match.group(7)!, // 112
dutyNumber: match.group(4)!, // 518
runningNumber: match.group(6)!, // 518
busFleetNumber: match.group(3)!, // EC518
tripType: match.group(5) ?? '', // N/R/F or empty
);
}
Pattern 2: Finishing Trips
RegExp finishingTripPattern = RegExp(
r'^(\d{4})_(\d{4})\s+.*FINISH.*\s+(EC\d+)\s+(\d+)\s+F\s+(\d+)'
);
Trip? parseFinishingTrip(String line) {
final match = finishingTripPattern.firstMatch(line);
if (match == null) return null;
return Trip(
scheduledTime: formatTime(match.group(1)!), // 1521 -> "15:21"
tripNumber: match.group(5)!, // 110
dutyNumber: match.group(4)!, // 509
runningNumber: match.group(4)!, // Same as duty for finishing trips
busFleetNumber: match.group(3)!, // EC509
tripType: 'F', // Finishing trip
isFinishing: true,
);
}
Step 4: Time Formatting
String formatTime(String rawTime) {
// Convert 1531 -> "15:31"
if (rawTime.length != 4) throw FormatException('Invalid time: $rawTime');
final hours = rawTime.substring(0, 2);
final minutes = rawTime.substring(2, 4);
return '$hours:$minutes';
}
Step 5: Filter by Time Range
List<Trip> filterByTimeRange(List<Trip> trips, String startTime) {
return trips.where((trip) => trip.scheduledTime.compareTo(startTime) >= 0).toList();
}
Complete Parser Implementation
class ArrivaScheduleParser implements ScheduleParser {
static final _regularPattern = RegExp(
r'^(\d{4})_(\d{4})\s+.*\s+(EC\d+)\s+(\d+)\s+([NRF]?)(\d+)\s+(\d+)\s+(\d{4})\s+(\d+)'
);
static final _finishingPattern = RegExp(
r'^(\d{4})_(\d{4})\s+.*FINISH.*\s+(EC\d+)\s+(\d+)\s+F\s+(\d+)'
);
@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>> parse(String filePath) async {
// Step 1: Extract text
final text = await extractTextFromDocx(filePath);
// Step 2: Find trip lines
final tripLines = extractTripLines(text);
if (tripLines.isEmpty) {
throw ScheduleParseException('No trip data found in schedule');
}
// Step 3: Parse each line
final trips = <Trip>[];
for (final line in tripLines) {
Trip? trip;
// Try regular pattern first
trip = _parseRegularTrip(line);
// If not regular, try finishing pattern
trip ??= _parseFinishingTrip(line);
if (trip != null) {
trips.add(trip);
}
}
// Step 4: Sort by scheduled time
trips.sort((a, b) => a.scheduledTime.compareTo(b.scheduledTime));
return trips;
}
Trip? _parseRegularTrip(String line) {
final match = _regularPattern.firstMatch(line);
if (match == null) return null;
return Trip(
scheduledTime: _formatTime(match.group(1)!),
tripNumber: match.group(7)!,
dutyNumber: match.group(4)!,
runningNumber: match.group(6)!,
busFleetNumber: match.group(3)!,
tripType: match.group(5) ?? '',
isFinishing: false,
);
}
Trip? _parseFinishingTrip(String line) {
final match = _finishingPattern.firstMatch(line);
if (match == null) return null;
return Trip(
scheduledTime: _formatTime(match.group(1)!),
tripNumber: match.group(5)!,
dutyNumber: match.group(4)!,
runningNumber: match.group(4)!,
busFleetNumber: match.group(3)!,
tripType: 'F',
isFinishing: true,
);
}
String _formatTime(String rawTime) {
if (rawTime.length != 4) {
throw FormatException('Invalid time format: $rawTime');
}
return '${rawTime.substring(0, 2)}:${rawTime.substring(2, 4)}';
}
List<String> extractTripLines(String text) {
final lines = text.split('\n');
return lines
.where((line) =>
RegExp(r'^\d{4}_\d{4}').hasMatch(line) &&
line.contains('EC'))
.map((line) => line.trim())
.toList();
}
}
Error Handling
class ScheduleParseException implements Exception {
final String message;
ScheduleParseException(this.message);
@override
String toString() => 'ScheduleParseException: $message';
}
// Usage in parser
try {
final trips = await parser.parse(filePath);
} on ScheduleParseException catch (e) {
// Show user-friendly error
showError('Could not parse schedule: ${e.message}');
} catch (e) {
// Unexpected error
showError('An unexpected error occurred');
logError(e);
}
Validation
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 fleet number format
if (!RegExp(r'^EC\d{3,4}$').hasMatch(trip.busFleetNumber)) {
errors.add('Invalid fleet number: ${trip.busFleetNumber}');
}
// Validate duty/running numbers
if (trip.dutyNumber.isEmpty || trip.runningNumber.isEmpty) {
errors.add('Missing duty or running number');
}
return errors;
}
static bool isValid(Trip trip) {
return validate(trip).isEmpty;
}
}
Testing the Parser
void main() async {
final parser = ArrivaScheduleParser();
// Test with sample schedule
final trips = await parser.parse('/path/to/schedule.docx');
print('Parsed ${trips.length} trips');
// Validate all trips
for (final trip in trips) {
final errors = TripValidator.validate(trip);
if (errors.isNotEmpty) {
print('Trip ${trip.tripNumber} has errors: $errors');
}
}
// Filter to 15:00 onwards
final filteredTrips = trips.where((t) => t.scheduledTime.compareTo('15:00') >= 0).toList();
print('${filteredTrips.length} trips from 15:00 onwards');
// Print first few trips
for (final trip in filteredTrips.take(5)) {
print('${trip.scheduledTime} - Trip ${trip.tripNumber} - ${trip.busFleetNumber}');
}
}
Key Parsing Insights
- Departure Time: First time in the
HHMM_HHMMpattern is the actual departure - Fleet Numbers: Always prefixed with
ECfor Arriva (varies by TOC) - Trip Types:
- Empty string = regular trip
N= Next trip has note/changeR= Relief (driver change)F= Finishing trip
- Finishing Trips: Look for
FINISH AT POINTtext andFflag - Time Filtering: Use string comparison (works because HH:MM format sorts correctly)
Common Parsing Pitfalls
- Inconsistent whitespace: Use
\s+instead of single space in regex - Missing data: Not all trips have all fields - handle nulls
- Time wraparound: Services past midnight (00:00-05:59) should sort after 23:59
- Multiple sections: Schedule may have outbound AND inbound - filter correctly
- Dead running: Some services show but aren't in passenger service - may need filtering
2. Trip List View
Functionality:
- Display all parsed trips in scrollable list
- Show pre-filled data:
- Scheduled departure time
- Trip number
- Bus fleet number
- Duty number
- Running number
- Highlight unfilled required fields (actual departure time, actual fleet)
- Filter by time range (e.g., "15:00 onwards")
- Sort by scheduled time (default)
UI Components:
- List/ListView with trip cards
- Each card shows:
- Scheduled time (read-only, bold)
- Trip number badge
- Editable fields for actual departure time and fleet
- Status indicator (complete/incomplete)
3. Data Entry Interface
Functionality:
- Quick entry for:
- Actual departure time (time picker or manual entry)
- Actual fleet number (text input with validation)
- Validation:
- Fleet number format (e.g., EC### or ####)
- Time format (HH:MM)
- Flag if actual time significantly differs from scheduled
- Keyboard optimization for numeric entry
- Quick "same as scheduled" button for times
- Quick "same as planned" button for fleet
UX Considerations:
- Focus on speed - controller may be entering data between radio calls
- Large tap targets
- Auto-advance to next trip after entry
- Save progress automatically (local storage)
4. BRR Export
Functionality:
- Generate Excel (.xlsx) file in BRR template format
- Include all trip data:
- Scheduled departure times (pre-filled)
- Trip numbers (pre-filled)
- Actual departure times (user-entered)
- Fleet numbers (user-entered)
- Duty and running numbers (pre-filled)
- Preserve BRR template formatting
- Export to device storage
- Share via standard system share sheet
Technical Requirements:
- Excel generation library (e.g.,
excelpackage) - Template structure matching standard BRR format
- File system access for saving
- Share functionality
MVP Implementation:
- Initial template based on Arriva BRR format
- Template system designed to support multiple TOC formats in future
User Flow:
- User taps "Export BRR"
- App validates all required fields filled
- Generate .xlsx file
- Save to device
- Show success message with option to share
BRR Export Implementation
Understanding BRR Excel Structure
The BRR template has a specific structure:
- Header rows (1-6): Title, metadata, column headers
- Data rows (7+): One row per trip
- Columns:
- A: Scheduled Departure Time
- B: Trip No.
- C: Actual Departure Time (user input)
- D: Fleet No. (user input)
- E: Duty Number
- F: Running No.
- G-M: Additional fields (Lost Mileage, Standby, etc.)
Excel Generation Strategy
Step 1: Setup Excel Package
import 'package:excel/excel.dart';
import 'dart:io';
class BRRExporter {
final Excel _excel;
late Sheet _sheet;
BRRExporter() : _excel = Excel.createExcel();
Future<File> generateBRR(List<Trip> trips, BRRMetadata metadata) async {
_sheet = _excel['Sheet1'];
// Build the BRR
_createHeader(metadata);
_createColumnHeaders();
_populateTrips(trips);
_formatCells();
// Save to file
return await _saveToFile(metadata);
}
}
Step 2: Create Header Section
void _createHeader(BRRMetadata metadata) {
// Row 1: Title
_sheet.cell(CellIndex.indexByString('A1')).value = 'Break';
_sheet.cell(CellIndex.indexByString('M1')).value = 'Sheet no:';
// Row 2: Location
_sheet.cell(CellIndex.indexByString('A2')).value = 'Location';
_sheet.cell(CellIndex.indexByString('M2')).value = metadata.date;
// Row 3: Title
_sheet.cell(CellIndex.indexByString('A3')).value = 'Rail Replacement BRR';
// Row 4: Start/Finish times
_sheet.cell(CellIndex.indexByString('A4')).value = 'Start Time';
_sheet.cell(CellIndex.indexByString('D4')).value = 'Finish Time';
_sheet.cell(CellIndex.indexByString('F4')).value =
'Every published departure time needs to be recorded and reason given if not operated from each location';
}
void _createColumnHeaders() {
// Row 5: Main column headers
final headers = [
'Scheduled Departure Time',
'Trip No.',
'Actual Departure Time',
'Fleet No.',
'Duty Number',
'Running No.',
'Did Trip Operate (Y/N)',
'Lost Milage (Tick Appropriate)',
'', // Staff
'', // Mech
'', // Traffic
'', // Non Deduct
'', // Other
'Standby used (Y/N)',
'Return trip if curtailed',
'Curtailment Details',
'', // From
'', // To
'Reason or comments'
];
for (var i = 0; i < headers.length; i++) {
_sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 4))
.value = headers[i];
}
// Row 6: Sub-headers for Lost Mileage
_sheet.cell(CellIndex.indexByString('H6')).value = 'Staff';
_sheet.cell(CellIndex.indexByString('I6')).value = 'Mech';
_sheet.cell(CellIndex.indexByString('J6')).value = 'Traffic';
_sheet.cell(CellIndex.indexByString('K6')).value = 'Non Deduct';
_sheet.cell(CellIndex.indexByString('L6')).value = 'Other';
}
Step 3: Populate Trip Data
void _populateTrips(List<Trip> trips) {
int startRow = 6; // Data starts at row 7 (index 6)
for (var i = 0; i < trips.length; i++) {
final trip = trips[i];
final rowIndex = startRow + i;
// Column A: Scheduled Departure Time
_sheet.cell(CellIndex.indexByColumnRow(
columnIndex: 0,
rowIndex: rowIndex
)).value = trip.scheduledTime;
// Column B: Trip No.
_sheet.cell(CellIndex.indexByColumnRow(
columnIndex: 1,
rowIndex: rowIndex
)).value = trip.tripNumber;
// Column C: Actual Departure Time (user input)
if (trip.actualDepartureTime != null) {
_sheet.cell(CellIndex.indexByColumnRow(
columnIndex: 2,
rowIndex: rowIndex
)).value = trip.actualDepartureTime;
}
// Column D: Fleet No. (user input)
if (trip.actualFleetNumber != null) {
_sheet.cell(CellIndex.indexByColumnRow(
columnIndex: 3,
rowIndex: rowIndex
)).value = trip.actualFleetNumber;
}
// Column E: Duty Number
_sheet.cell(CellIndex.indexByColumnRow(
columnIndex: 4,
rowIndex: rowIndex
)).value = trip.dutyNumber;
// Column F: Running No.
_sheet.cell(CellIndex.indexByColumnRow(
columnIndex: 5,
rowIndex: rowIndex
)).value = trip.runningNumber;
}
}
Step 4: Format Cells
void _formatCells() {
// Define styles
final headerStyle = CellStyle(
bold: true,
fontSize: 12,
horizontalAlign: HorizontalAlign.Center,
backgroundColorHex: '#D9D9D9',
);
final dataStyle = CellStyle(
fontSize: 11,
horizontalAlign: HorizontalAlign.Left,
);
// Apply header styles (rows 1-5)
for (var row = 0; row < 6; row++) {
for (var col = 0; col < 19; col++) {
final cell = _sheet.cell(CellIndex.indexByColumnRow(
columnIndex: col,
rowIndex: row
));
cell.cellStyle = headerStyle;
}
}
// Apply data styles (row 7 onwards)
final maxRow = _sheet.maxRows;
for (var row = 6; row < maxRow; row++) {
for (var col = 0; col < 19; col++) {
final cell = _sheet.cell(CellIndex.indexByColumnRow(
columnIndex: col,
rowIndex: row
));
cell.cellStyle = dataStyle;
}
}
// Set column widths
_sheet.setColWidth(0, 25.0); // Scheduled Departure Time
_sheet.setColWidth(1, 10.0); // Trip No.
_sheet.setColWidth(2, 25.0); // Actual Departure Time
_sheet.setColWidth(3, 12.0); // Fleet No.
_sheet.setColWidth(4, 15.0); // Duty Number
_sheet.setColWidth(5, 15.0); // Running No.
}
Step 5: Save to File
Future<File> _saveToFile(BRRMetadata metadata) async {
// Get output directory
final directory = await getApplicationDocumentsDirectory();
final fileName = 'BRR_${metadata.route}_${metadata.date}.xlsx';
final filePath = '${directory.path}/$fileName';
// Encode to bytes
final bytes = _excel.encode();
if (bytes == null) {
throw Exception('Failed to encode Excel file');
}
// Write to file
final file = File(filePath);
await file.writeAsBytes(bytes);
return file;
}
Complete Exporter Implementation
class ArrivaBRRExporter implements BRRExporter {
@override
Future<File> export(List<Trip> trips, BRRMetadata metadata) async {
final excel = Excel.createExcel();
final sheet = excel['Sheet1'];
// Build BRR structure
_buildHeader(sheet, metadata);
_buildColumnHeaders(sheet);
_populateData(sheet, trips);
_applyFormatting(sheet, trips.length);
// Save and return file
return await _saveFile(excel, metadata);
}
void _buildHeader(Sheet sheet, BRRMetadata metadata) {
// Row 1
sheet.cell(CellIndex.indexByString('A1')).value = 'Break';
sheet.cell(CellIndex.indexByString('M1')).value = 'Sheet no:';
// Row 2
sheet.cell(CellIndex.indexByString('A2')).value = 'Location';
sheet.cell(CellIndex.indexByString('B2')).value = metadata.location;
// Row 3
sheet.cell(CellIndex.indexByString('A3')).value = 'Rail Replacement BRR';
// Row 4
sheet.cell(CellIndex.indexByString('A4')).value = 'Start Time';
sheet.cell(CellIndex.indexByString('D4')).value = 'Finish Time';
// Row 5: Main headers
final headers = [
'Scheduled Departure Time', 'Trip No.', 'Actual Departure Time',
'Fleet No.', 'Duty Number', 'Running No.', 'Did Trip Operate (Y/N)',
'Lost Milage', '', '', '', '', 'Standby used (Y/N)',
'Return trip if curtailed', 'Curtailment Details', '', '',
'Reason or comments'
];
for (var i = 0; i < headers.length; i++) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 4))
.value = headers[i];
}
// Row 6: Sub-headers
final subHeaders = ['', '', '', '', '', '', '', 'Staff', 'Mech',
'Traffic', 'Non Deduct', 'Other', '', '', '', 'From', 'To', ''];
for (var i = 0; i < subHeaders.length; i++) {
if (subHeaders[i].isNotEmpty) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 5))
.value = subHeaders[i];
}
}
}
void _buildColumnHeaders(Sheet sheet) {
// Already done in _buildHeader
}
void _populateData(Sheet sheet, List<Trip> trips) {
for (var i = 0; i < trips.length; i++) {
final trip = trips[i];
final row = 6 + i; // Data starts at row 7 (index 6)
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: row))
.value = trip.scheduledTime;
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: row))
.value = trip.tripNumber;
if (trip.actualDepartureTime != null) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: row))
.value = trip.actualDepartureTime;
}
if (trip.actualFleetNumber != null) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: row))
.value = trip.actualFleetNumber;
}
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: row))
.value = trip.dutyNumber;
sheet.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: row))
.value = trip.runningNumber;
}
}
void _applyFormatting(Sheet sheet, int tripCount) {
// Header style
final headerStyle = CellStyle(
bold: true,
fontSize: 12,
horizontalAlign: HorizontalAlign.Center,
backgroundColorHex: '#D9D9D9',
);
// Apply to header rows (0-5)
for (var row = 0; row < 6; row++) {
for (var col = 0; col < 18; col++) {
sheet.cell(CellIndex.indexByColumnRow(
columnIndex: col, rowIndex: row
)).cellStyle = headerStyle;
}
}
// Data style
final dataStyle = CellStyle(
fontSize: 11,
horizontalAlign: HorizontalAlign.Left,
);
// Apply to data rows
for (var row = 6; row < 6 + tripCount; row++) {
for (var col = 0; col < 18; col++) {
sheet.cell(CellIndex.indexByColumnRow(
columnIndex: col, rowIndex: row
)).cellStyle = dataStyle;
}
}
// Column widths
sheet.setColWidth(0, 25); // Scheduled time
sheet.setColWidth(1, 10); // Trip no
sheet.setColWidth(2, 25); // Actual time
sheet.setColWidth(3, 12); // Fleet
sheet.setColWidth(4, 15); // Duty
sheet.setColWidth(5, 15); // Running
}
Future<File> _saveFile(Excel excel, BRRMetadata metadata) async {
final directory = await getApplicationDocumentsDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = 'BRR_${metadata.route}_${metadata.date}_$timestamp.xlsx';
final filePath = '${directory.path}/$fileName';
final bytes = excel.encode();
if (bytes == null) throw Exception('Failed to encode Excel');
final file = File(filePath);
await file.writeAsBytes(bytes);
return file;
}
}
Metadata Class
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',
);
}
}
Export with Validation
class BRRExportService {
final BRRExporter _exporter = ArrivaBRRExporter();
Future<ExportResult> exportBRR(
List<Trip> trips,
BRRMetadata metadata,
) async {
// Validate trips
final validationErrors = <String>[];
for (final trip in trips) {
if (trip.actualDepartureTime == null) {
validationErrors.add('Trip ${trip.tripNumber}: Missing actual departure time');
}
if (trip.actualFleetNumber == null) {
validationErrors.add('Trip ${trip.tripNumber}: Missing fleet number');
}
}
if (validationErrors.isNotEmpty) {
return ExportResult.error(validationErrors);
}
try {
final file = await _exporter.export(trips, metadata);
return ExportResult.success(file);
} catch (e) {
return ExportResult.error(['Export failed: $e']);
}
}
}
class ExportResult {
final File? file;
final List<String> errors;
bool get isSuccess => errors.isEmpty;
ExportResult.success(this.file) : errors = [];
ExportResult.error(this.errors) : file = null;
}
Sharing the File
import 'package:share_plus/share_plus.dart';
Future<void> shareFile(File file) async {
await Share.shareXFiles(
[XFile(file.path)],
subject: 'BRR ${DateTime.now().toString().split(' ')[0]}',
text: 'Bus Running Report',
);
}
Usage Example
// In your export button handler
Future<void> _handleExport() async {
// Show loading
setState(() => _isExporting = true);
try {
final metadata = BRRMetadata(
route: 'Metropolitan_Line_Uxbridge',
date: '21_09_2025',
location: 'Uxbridge Station',
controller: 'Benjamin Watt',
);
final exportService = BRRExportService();
final result = await exportService.exportBRR(_trips, metadata);
if (result.isSuccess) {
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('BRR exported successfully')),
);
// Offer to share
final share = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Export Complete'),
content: Text('Would you like to share the BRR?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Not Now'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Share'),
),
],
),
);
if (share == true && result.file != null) {
await shareFile(result.file!);
}
} else {
// Show errors
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Cannot Export'),
content: Text(result.errors.join('\n')),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
),
);
}
} finally {
setState(() => _isExporting = false);
}
}
Key Export Insights
- Template Preservation: Maintain exact header structure for compatibility
- Cell Formatting: Use consistent styles (bold headers, aligned data)
- Column Widths: Set appropriate widths for readability
- Validation: Check all required fields before export
- File Naming: Use descriptive names with route, date, timestamp
- Error Handling: Graceful failure with user-friendly messages
- Sharing: Offer immediate sharing after successful export
Data Model
Trip Object
class Trip {
String scheduledTime; // "15:31"
String tripNumber; // "112"
String dutyNumber; // "518"
String runningNumber; // "518"
String busFleetNumber; // "EC518"
String? actualDepartureTime; // "15:33" (nullable - user input)
String? actualFleetNumber; // "33523" (nullable - user input)
bool isComplete; // Computed: both actuals filled
}
App State
class BRRState {
List<Trip> trips;
String? scheduleFileName;
DateTime? uploadedAt;
String? routeInfo; // e.g., "Metropolitan Line - Uxbridge"
String? serviceDate; // "21/09/2025"
}
Technical Stack
UI Framework
- UI Library:
shadcn_flutter: ^0.0.47(NO Material or Cupertino components) - Icons: Lucide Icons
- Routing:
go_routerwith routes defined in page classes
Routing Pattern
class HomePage extends StatefulWidget {
static GoRoute route = GoRoute(
path: "/",
builder: (context, state) => HomePage(),
);
@override
State<HomePage> createState() => _HomePageState();
}
Required Packages
shadcn_flutter: ^0.0.47- UI componentslucide_icons- Iconsgo_router- Navigationfile_picker- Document uploaddocx_to_textor custom parser - Schedule parsingexcel- BRR generationshare_plus- File sharingpath_provider- File system accessshared_preferences- Local data persistence
Platform Support
- Primary: Android (most controllers use Android devices)
- Secondary: iOS (if time permits)
- Future: Web version for desktop use
User Interface
UI Design Reference
The app should follow the visual pattern of First's Coordination Portal V2.0:
- List-based layout with cards for each trip
- Information density - show key data at a glance
- Expandable details - tap to show/edit full trip information
- Clean typography - labels in bold blue, data in black
- Header bar showing station/location, user, and date
- Tab/filter system for viewing different subsets (All/Arrival/Departure/Standby)
Key UI Elements from Reference
Time: 16:31 Type: D Coach: 438
Vehicle Type: Double Deck Bus
Customer: South Western Railway
Destination: Station Forecourt off Station Parade
Operator: Imperial Coaches Ltd
VRM: YX25OJN
Each trip card should show:
- Primary info (large): Time, Type indicator, Coach/Fleet number
- Secondary info (smaller): Vehicle type, destination, operator
- Expandable section for data entry and additional details
Screens
-
Home/Upload Screen
- Upload button (prominent)
- Recent BRRs list (if any saved)
- App info/help button
-
Trip List Screen
- Header bar:
- Route info (e.g., "Metropolitan Line - Uxbridge")
- User name
- Service date with prev/next navigation
- Tab bar:
- All trips
- Departure (outbound from start point)
- Arrival (return trips)
- Standby (if applicable)
- Trip cards:
- Collapsed view: Time, Type, Coach number
- Tap to expand: Full details + editable fields
- Bottom action bar:
- Completion progress indicator
- "Export BRR" button
- Header bar:
-
Trip Detail/Entry (Expanded Card)
- All scheduled data (read-only, gray background)
- Editable fields (white background):
- Actual departure time
- Actual fleet number
- Quick action buttons:
- "Same as scheduled" (time)
- "As planned" (fleet)
- Status indicator (complete/incomplete)
Design Principles
- High contrast - usable in bright daylight
- Large text - readable at a glance
- Minimal taps - optimize for speed
- Offline-first - works without internet
- Auto-save - never lose entered data
- Information hierarchy - most important data largest/boldest
- Touch-friendly - 44pt minimum tap targets
- Collapsible content - reduce scrolling, expand only what's needed
Out of Scope for MVP
Future Features
- Multi-TOC support - Parser and template support for First, Stagecoach, National Express, etc.
- Multi-route support in single session
- Photo upload for schedule documents (OCR)
- Sync across devices
- Historical BRR archive
- Analytics/reports (trips per duty, delays, etc.)
- Did Trip Operate (Y/N) tracking
- Lost mileage reasons
- Curtailment details
- Integration with radio/messaging systems
- Offline schedule library
- Duty roster integration
- Real-time collaboration (multiple controllers on same BRR)
Technical Debt Acceptable for MVP
- Basic error handling (can improve later)
- Simple validation (can add more rules later)
- Single schedule format parser (Arriva only)
- No user authentication
- No cloud sync
- No data encryption (local storage only)
Success Metrics
MVP Success Criteria
- Parse Arriva schedule accurately (>95% accuracy)
- Generate valid BRR Excel file
- Reduce data entry time by >50% vs manual
- Zero crashes during normal operation
- Works offline
User Acceptance
- Controller can complete a 40-trip BRR in <10 minutes (vs 30+ minutes manual)
- No need to reference paper schedule while entering data
- Generated BRR passes management review without corrections
Development Phases
Phase 1: Core Parsing (Week 1)
- Schedule upload
- Docx parsing
- Trip data extraction
- Basic validation
Phase 2: Data Entry UI (Week 2)
- Trip list view
- Input forms
- Auto-save
- Validation feedback
Phase 3: Export & Polish (Week 3)
- Excel generation
- Template formatting
- File sharing
- Bug fixes
- UX refinements
Testing Requirements
Critical Test Cases
- Upload valid schedule → correct trip count
- Parse trips → all fields populated correctly
- Enter actual times/fleet → data saved
- Export BRR → valid Excel file
- Re-open app → previous data persisted
- Handle missing fields → appropriate error messages
- Handle malformed schedule → graceful failure
Test Data
- Primary test data: Arriva Metropolitan Line schedule (21/09/2025)
- Minimum 3 different Arriva schedule formats for validation
- Edge cases: early morning times (00:00-06:00), finishing trips (F flag)
Future Testing (Post-MVP)
- Test with First, Stagecoach, and other TOC schedule formats
- Cross-TOC compatibility validation
Deployment
MVP Release
- Platform: Android APK
- Distribution: Direct download (no app store initially)
- Version: 0.1.0-alpha
- Target Users: Benji + 2-3 other controllers for feedback
Post-MVP
- Google Play Store release
- iOS App Store (if demand)
- Feature iterations based on user feedback
Notes
- MVP uses Arriva schedule format for initial development and testing
- Architecture should support multiple TOC formats in future releases
- Parser should be modular/pluggable for easy addition of new formats
- BRR template matches existing Excel format used across industry
- App must be usable with gloves (winter operations)
- Consider dark mode (night shifts)
Architecture Considerations for Multi-TOC Support
Parser Design
abstract class ScheduleParser {
List<Trip> parse(String docxContent);
bool canParse(String docxContent); // Detect format
}
class ArrivaScheduleParser extends ScheduleParser { ... }
// Future: FirstScheduleParser, StagecoachScheduleParser, etc.
Template Design
abstract class BRRTemplate {
File generate(List<Trip> trips, BRRMetadata metadata);
}
class ArrivaBRRTemplate extends BRRTemplate { ... }
// Future: FirstBRRTemplate, StagecoachBRRTemplate, etc.