Roadbound-BRR/MVP.md

1275 lines
No EOL
36 KiB
Markdown

# 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_text` or 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:**
1. User taps "Upload Schedule"
2. Select .docx file from device
3. App parses and displays trip count
4. 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:
1. **Header Section** - Route information and metadata
2. **Column Headers** - Station/timing point names
3. **Trip Lines** - Individual service data
4. **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 route
- `1618 1624 1624` - Arrival times at destination
- `EC518` - Bus fleet number
- `518` - Duty number
- `518` - Running number
- `112` - Trip number (current)
- `1635` - Next scheduled time
- `115` - 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 terminates
- `F` flag instead of running number
- `110` - Trip number (no next trip)
### Parsing Strategy
#### Step 1: Extract Raw Text from DOCX
```dart
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
```dart
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**
```dart
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**
```dart
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
```dart
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
```dart
List<Trip> filterByTimeRange(List<Trip> trips, String startTime) {
return trips.where((trip) => trip.scheduledTime.compareTo(startTime) >= 0).toList();
}
```
### Complete Parser Implementation
```dart
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
```dart
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
```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 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
```dart
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
1. **Departure Time**: First time in the `HHMM_HHMM` pattern is the actual departure
2. **Fleet Numbers**: Always prefixed with `EC` for Arriva (varies by TOC)
3. **Trip Types**:
- Empty string = regular trip
- `N` = Next trip has note/change
- `R` = Relief (driver change)
- `F` = Finishing trip
4. **Finishing Trips**: Look for `FINISH AT POINT` text and `F` flag
5. **Time Filtering**: Use string comparison (works because HH:MM format sorts correctly)
### Common Parsing Pitfalls
1. **Inconsistent whitespace**: Use `\s+` instead of single space in regex
2. **Missing data**: Not all trips have all fields - handle nulls
3. **Time wraparound**: Services past midnight (00:00-05:59) should sort after 23:59
4. **Multiple sections**: Schedule may have outbound AND inbound - filter correctly
5. **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., `excel` package)
- 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:**
1. User taps "Export BRR"
2. App validates all required fields filled
3. Generate .xlsx file
4. Save to device
5. 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
```dart
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
```dart
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
```dart
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
```dart
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
```dart
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
```dart
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
```dart
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
```dart
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
```dart
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
```dart
// 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
1. **Template Preservation**: Maintain exact header structure for compatibility
2. **Cell Formatting**: Use consistent styles (bold headers, aligned data)
3. **Column Widths**: Set appropriate widths for readability
4. **Validation**: Check all required fields before export
5. **File Naming**: Use descriptive names with route, date, timestamp
6. **Error Handling**: Graceful failure with user-friendly messages
7. **Sharing**: Offer immediate sharing after successful export
## Data Model
### Trip Object
```dart
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
```dart
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_router` with routes defined in page classes
### Routing Pattern
```dart
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 components
- `lucide_icons` - Icons
- `go_router` - Navigation
- `file_picker` - Document upload
- `docx_to_text` or custom parser - Schedule parsing
- `excel` - BRR generation
- `share_plus` - File sharing
- `path_provider` - File system access
- `shared_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
1. **Home/Upload Screen**
- Upload button (prominent)
- Recent BRRs list (if any saved)
- App info/help button
2. **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
3. **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
1. Parse Arriva schedule accurately (>95% accuracy)
2. Generate valid BRR Excel file
3. Reduce data entry time by >50% vs manual
4. Zero crashes during normal operation
5. 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
1. Upload valid schedule correct trip count
2. Parse trips all fields populated correctly
3. Enter actual times/fleet data saved
4. Export BRR valid Excel file
5. Re-open app previous data persisted
6. Handle missing fields appropriate error messages
7. 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
```dart
abstract class ScheduleParser {
List<Trip> parse(String docxContent);
bool canParse(String docxContent); // Detect format
}
class ArrivaScheduleParser extends ScheduleParser { ... }
// Future: FirstScheduleParser, StagecoachScheduleParser, etc.
```
### Template Design
```dart
abstract class BRRTemplate {
File generate(List<Trip> trips, BRRMetadata metadata);
}
class ArrivaBRRTemplate extends BRRTemplate { ... }
// Future: FirstBRRTemplate, StagecoachBRRTemplate, etc.
```