1275 lines
No EOL
36 KiB
Markdown
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.
|
|
``` |