Roadbound-BRR/MVP.md

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_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

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

  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

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

  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

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_router with 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 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

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.