import "dart:io"; import "dart:typed_data"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:file_saver/file_saver.dart"; import "package:go_router/go_router.dart"; import "package:path_provider/path_provider.dart"; import "package:share_plus/share_plus.dart"; import "../models/trip.dart"; import "../models/brr_metadata.dart"; import "../models/brr_state.dart"; import "../services/brr_export_service.dart"; import "../services/storage_service.dart"; class TripListPage extends StatefulWidget { final List trips; final String fileName; final bool fromStationSelection; final BRROperator operator; final String? routeName; const TripListPage({ super.key, required this.trips, required this.fileName, this.fromStationSelection = false, this.operator = BRROperator.arriva, this.routeName, }); static GoRoute route = GoRoute( path: "/trips", builder: (context, state) { final extra = state.extra as Map?; if (extra == null) { // extra is lost on hot reload — home will restore from storage WidgetsBinding.instance.addPostFrameCallback((_) { context.go("/"); }); return const SizedBox.shrink(); } return TripListPage( trips: (extra["trips"] as List).cast(), fileName: extra["fileName"] as String, fromStationSelection: extra.containsKey("station"), operator: extra["operator"] as BRROperator? ?? BRROperator.arriva, routeName: extra["routeName"] as String?, ); }, ); @override State createState() => _TripListPageState(); } class _TripListPageState extends State { late List _trips; bool _isExporting = false; String? _expandedTripId; final _storageService = StorageService(); @override void initState() { super.initState(); _trips = List.from(widget.trips); if (widget.operator == BRROperator.arriva) { _trips.sort((a, b) => int.tryParse(a.tripNumber)?.compareTo(int.tryParse(b.tripNumber) ?? 0) ?? a.tripNumber.compareTo(b.tripNumber)); } else { final firstMins = _toMins(_trips.first.scheduledTime); _trips.sort((a, b) => _sortableTime(a.scheduledTime, firstMins).compareTo(_sortableTime(b.scheduledTime, firstMins))); } _autoSave(); } int _toMins(String time) { final parts = time.split(":"); if (parts.length != 2) return 0; return (int.tryParse(parts[0]) ?? 0) * 60 + (int.tryParse(parts[1]) ?? 0); } // Anything earlier than the first trip's time is treated as next-day. int _sortableTime(String time, int thresholdMins) { final mins = _toMins(time); return mins < thresholdMins ? mins + 1440 : mins; } Future _autoSave() async { final state = BRRState( trips: _trips, scheduleFileName: widget.fileName, uploadedAt: DateTime.now(), ); await _storageService.saveState(state); } int get _completedCount => _trips.where((t) => t.isComplete).length; String get _exportFileName => "BRR_${DateTime.now().toString().split(" ")[0].replaceAll("-", "_")}.xlsx"; Future _handleExport() async { setState(() => _isExporting = true); try { final metadata = BRRMetadata( route: widget.routeName ?? "Unknown", date: DateTime.now().toString().split(" ")[0].replaceAll("-", "_"), location: "Unknown", controller: "Unknown", ); final exportService = BRRExportService(operator: widget.operator); final result = await exportService.exportBRR(_trips, metadata); if (!mounted) return; if (result.isSuccess) { await _saveFile(result.bytes!, _exportFileName); } else { if (mounted) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Cannot Export"), content: Text(result.errors.join("\n")), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("OK"), ), ], ), ); } } } finally { if (mounted) setState(() => _isExporting = false); } } Future _saveFile(Uint8List bytes, String fileName) async { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { final tmp = await getTemporaryDirectory(); final file = File("${tmp.path}/$fileName"); await file.writeAsBytes(bytes); await Share.shareXFiles( [XFile(file.path)], subject: "BRR ${DateTime.now().toString().split(" ")[0]}", ); } else { await FileSaver.instance.saveFile( name: fileName.replaceAll(".xlsx", ""), bytes: bytes, ext: "xlsx", mimeType: MimeType.microsoftExcel, ); } } void _updateTrip(int index, Trip updatedTrip) { setState(() { _trips[index] = updatedTrip; }); _autoSave(); } @override Widget build(BuildContext context) { final progress = _trips.isEmpty ? 0.0 : _completedCount / _trips.length; return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back, size: 20), onPressed: () async { await _storageService.clearState(); if (!mounted) return; if (widget.fromStationSelection) { context.go("/station-selection", extra: { "trips": widget.trips, "fileName": widget.fileName, }); } else { context.go("/"); } }, ), title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("TRIPS"), Text( widget.fileName, style: const TextStyle( fontSize: 11, color: Color(0xFF777777), fontWeight: FontWeight.w400, ), ), ], ), actions: [ Padding( padding: const EdgeInsets.only(right: 12), child: TextButton( onPressed: _isExporting ? null : _handleExport, style: TextButton.styleFrom( backgroundColor: const Color(0xFF00A9CE), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(3)), ), textStyle: const TextStyle( fontWeight: FontWeight.w700, fontSize: 12, letterSpacing: 0.5, ), ), child: _isExporting ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), ) : const Text("EXPORT"), ), ), ], ), body: Column( children: [ // progress bar Container( color: const Color(0xFF1A1A1A), padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "$_completedCount / ${_trips.length}", style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFFEEEEEE), fontFamily: "monospace", ), ), Text( "${(progress * 100).toStringAsFixed(0)}% complete", style: const TextStyle( fontSize: 12, color: Color(0xFF777777), ), ), ], ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: progress, minHeight: 4, backgroundColor: const Color(0xFF2E2E2E), valueColor: const AlwaysStoppedAnimation(Color(0xFF00A9CE)), ), ), ], ), ), Expanded( child: ListView.builder( itemCount: _trips.length, padding: const EdgeInsets.symmetric(vertical: 8), itemBuilder: (context, index) { final trip = _trips[index]; final tripId = "${trip.tripNumber}_$index"; final isExpanded = _expandedTripId == tripId; // find the next trip this duty runs after this one final nextDutyTrip = _trips .skip(index + 1) .where((t) => t.dutyNumber == trip.dutyNumber) .firstOrNull; return TripCard( trip: trip, nextDutyTrip: nextDutyTrip, isExpanded: isExpanded, onTap: () { setState(() { _expandedTripId = isExpanded ? null : tripId; }); }, onUpdate: (updatedTrip) => _updateTrip(index, updatedTrip), ); }, ), ), ], ), ); } } class TripCard extends StatefulWidget { final Trip trip; final Trip? nextDutyTrip; final bool isExpanded; final VoidCallback onTap; final Function(Trip) onUpdate; const TripCard({ super.key, required this.trip, this.nextDutyTrip, required this.isExpanded, required this.onTap, required this.onUpdate, }); @override State createState() => _TripCardState(); } class _TripCardState extends State { late TextEditingController _actualTimeController; late TextEditingController _actualFleetController; @override void initState() { super.initState(); _actualTimeController = TextEditingController(text: widget.trip.actualDepartureTime ?? ""); _actualFleetController = TextEditingController(text: widget.trip.actualFleetNumber ?? ""); } @override void dispose() { _actualTimeController.dispose(); _actualFleetController.dispose(); super.dispose(); } void _saveChanges() { final updated = widget.trip.copyWith( actualDepartureTime: _actualTimeController.text.isEmpty ? null : _actualTimeController.text, actualFleetNumber: _actualFleetController.text.isEmpty ? null : _actualFleetController.text, ); widget.onUpdate(updated); } void _setSameAsScheduled() { _actualTimeController.text = widget.trip.scheduledTime; _saveChanges(); } void _setNow() { final now = DateTime.now(); _actualTimeController.text = "${now.hour.toString().padLeft(2, "0")}:${now.minute.toString().padLeft(2, "0")}"; _saveChanges(); } @override Widget build(BuildContext context) { final isComplete = widget.trip.isComplete; return Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), decoration: BoxDecoration( color: const Color(0xFF1E1E1E), border: Border( left: BorderSide( color: isComplete ? const Color(0xFF4CAF50) : const Color(0xFF2E2E2E), width: 3, ), ), ), child: InkWell( onTap: widget.onTap, child: Column( children: [ // collapsed row Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Row( children: [ // time SizedBox( width: 72, child: Text( widget.trip.scheduledTime, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w900, fontFamily: "monospace", color: Color(0xFFEEEEEE), letterSpacing: -0.5, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: const Color(0xFF252525), border: Border.all(color: const Color(0xFF2E2E2E)), borderRadius: BorderRadius.circular(3), ), child: Text( "Trip ${widget.trip.tripNumber}", style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFFCCCCCC), ), ), ), const SizedBox(height: 2), ], ), ), if (widget.trip.isFinishing) Container( margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), color: const Color(0xFF2A1A00), child: const Text( "FIN", style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF00A9CE), letterSpacing: 1, ), ), ), Icon( widget.isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 18, color: const Color(0xFF555555), ), ], ), ), // expanded section if (widget.isExpanded) ...[ Container(height: 1, color: const Color(0xFF2E2E2E)), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // scheduled info grid Row( children: [ _InfoCell("DUTY", widget.trip.dutyNumber), const SizedBox(width: 12), _InfoCell("RUNNING", widget.trip.runningNumber), ], ), const SizedBox(height: 16), // actual departure time const Text( "ACTUAL TIME", style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF666666), letterSpacing: 1.5, ), ), const SizedBox(height: 6), Row( children: [ Expanded( child: SizedBox( height: 48, child: TextField( controller: _actualTimeController, style: const TextStyle( fontFamily: "monospace", fontSize: 16, fontWeight: FontWeight.w700, ), decoration: InputDecoration( hintText: "HH:MM", suffixIcon: _actualTimeController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.close, size: 16), onPressed: () { _actualTimeController.clear(); _saveChanges(); }, ) : null, ), onChanged: (_) { setState(() {}); _saveChanges(); }, ), ), ), const SizedBox(width: 8), SizedBox( height: 48, child: OutlinedButton( onPressed: _setNow, style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF999999), side: const BorderSide(color: Color(0xFF3A3A3A)), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(3)), ), padding: const EdgeInsets.symmetric(horizontal: 10), textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), ), child: const Text("NOW"), ), ), const SizedBox(width: 8), SizedBox( height: 48, child: OutlinedButton( onPressed: _setSameAsScheduled, style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF999999), side: const BorderSide(color: Color(0xFF3A3A3A)), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(3)), ), padding: const EdgeInsets.symmetric(horizontal: 10), textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), ), child: const Text("AS SCHED"), ), ), ], ), const SizedBox(height: 14), // actual fleet const Text( "ACTUAL FLEET", style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF666666), letterSpacing: 1.5, ), ), const SizedBox(height: 6), Row( children: [ Expanded( child: TextField( controller: _actualFleetController, style: const TextStyle( fontFamily: "monospace", fontSize: 16, fontWeight: FontWeight.w700, ), decoration: const InputDecoration( hintText: "EC###", ), onChanged: (_) => _saveChanges(), ), ), ], ), ], ), ), Container(height: 1, color: const Color(0xFF2E2E2E)), Padding( padding: const EdgeInsets.all(16.0), child: const Text( "SCHEDULE", style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF666666), letterSpacing: 1.5, ), ), ), _ScheduleDiagram( stations: widget.trip.stationOrder, stationTimes: widget.trip.stationTimes, nextDutyTrip: widget.nextDutyTrip, leftOffset: 16, ), Container(height: 1, color: const Color(0xFF2E2E2E)), SizedBox(height: 16), ], ), ], ], ), ), ); } } class _ScheduleDiagram extends StatelessWidget { final List stations; final Map stationTimes; final Trip? nextDutyTrip; final double leftOffset; const _ScheduleDiagram({required this.stations, required this.stationTimes, this.nextDutyTrip, this.leftOffset = 0}); static const double rowHeight = 32; static const double lineW = 7; static const double dotSize = 15; @override Widget build(BuildContext context) { final lineColor = Theme.of(context).colorScheme.primary; final placeholderLabel = nextDutyTrip != null ? "BECOMES: TRIP ${nextDutyTrip!.tripNumber}" : "END OF DUTY"; final placeholderTime = nextDutyTrip?.scheduledTime; // deduplicate stops with the same name + time anywhere in the list final deduped = []; final seen = {}; for (final s in stations) { final key = "$s|${stationTimes[s]}"; if (seen.contains(key)) continue; seen.add(key); deduped.add(s); } final displayStations = [...deduped, placeholderLabel]; final displayTimes = Map.from(stationTimes); if (placeholderTime != null) displayTimes[placeholderLabel] = placeholderTime; final totalHeight = displayStations.length * rowHeight; return Stack( children: [ // the painted line + dots behind the labels SizedBox( height: totalHeight, width: double.infinity, child: CustomPaint( painter: _LineDiagramPainter( count: displayStations.length, rowHeight: rowHeight, lineW: lineW, dotSize: dotSize, lineColor: lineColor, leftOffset: leftOffset, ), ), ), // labels column on top Positioned.fill(child: Column( children: List.generate(displayStations.length, (i) { final station = displayStations[i]; final time = displayTimes[station]; return Container( height: rowHeight, color: i == displayStations.length - 1 ? lineColor.withValues(alpha: 0.12) : i.isOdd ? Colors.white.withValues(alpha: 0.03) : null, child: Padding( padding: EdgeInsets.only(left: leftOffset + 26, right: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( station, style: const TextStyle( fontSize: 13, color: Color(0xFFDDDDDD), fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), Text( time ?? "--:--", style: TextStyle( fontSize: 13, fontFamily: "monospace", fontWeight: FontWeight.w700, color: time != null ? const Color(0xFFEEEEEE) : const Color(0xFF555555), ), ), ], ), ), ); }), )), ], ); } } class _LineDiagramPainter extends CustomPainter { final int count; final double rowHeight; final double lineW; final double dotSize; final Color lineColor; // metro_map_maker constants, scaled ~3.5x (world units → screen px, base line = 6px world) static const double _linePad = 3.0; static const double _blobPad = 4.0; static const double _centerSize = 4.0; static const double _cornerFactor = 0.3; final double leftOffset; _LineDiagramPainter({ required this.count, required this.rowHeight, required this.lineW, required this.dotSize, required this.lineColor, this.leftOffset = 0, }); @override void paint(Canvas canvas, Size size) { // derived — mirrors metro_map_maker getStationRingGeometry final strokeW = lineW / 2.0; // baseStrokeWidth final ringSize = _centerSize + strokeW; // kStationCenterSize + strokeW final paddingWidth = strokeW + _blobPad; // kStationBlobPaddingWidth + strokeW // cx: enough left margin for the station blob outline, plus caller offset final cx = leftOffset + ringSize / 2 + paddingWidth / 2 + 2; final firstY = rowHeight / 2; final lastY = (count - 1) * rowHeight + rowHeight / 2; // build rrects once for reuse final rrects = List.generate(count, (i) { final cy = i * rowHeight + rowHeight / 2; final rect = Rect.fromCenter(center: Offset(cx, cy), width: ringSize, height: ringSize); return RRect.fromRectAndRadius(rect, Radius.circular(ringSize * _cornerFactor)); }); // ── pass 1: station white padding (under everything) ───────────── for (final rrect in rrects) { canvas.drawRRect( rrect, Paint() ..color = Colors.white ..strokeWidth = paddingWidth ..style = PaintingStyle.stroke, ); } // ── pass 2: line white padding ──────────────────────────────────── canvas.drawLine( Offset(cx, firstY), Offset(cx, lastY), Paint() ..color = Colors.white ..strokeWidth = lineW + _linePad ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke, ); // ── pass 3: colored line ────────────────────────────────────────── canvas.drawLine( Offset(cx, firstY), Offset(cx, lastY), Paint() ..color = lineColor ..strokeWidth = lineW ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke, ); // ── pass 4: station white fill + colored ring (on top of line) ──── for (final rrect in rrects) { final centerRRect = rrect.deflate(strokeW / 2); canvas.drawRRect(centerRRect, Paint()..color = Colors.white..style = PaintingStyle.fill); canvas.drawRRect( rrect, Paint() ..color = lineColor ..strokeWidth = strokeW ..style = PaintingStyle.stroke, ); } } @override bool shouldRepaint(_LineDiagramPainter old) => old.count != count || old.lineColor != lineColor || old.lineW != lineW || old.leftOffset != leftOffset; } class _InfoCell extends StatelessWidget { final String label; final String value; const _InfoCell(this.label, this.value); @override Widget build(BuildContext context) { return Expanded( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), color: const Color(0xFF252525), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF666666), letterSpacing: 1.2, ), ), const SizedBox(height: 3), Text( value, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFFCCCCCC), fontFamily: "monospace", ), overflow: TextOverflow.ellipsis, ), ], ), ), ); } }