Roadbound-BRR/lib/pages/trip_list_page.dart

872 lines
29 KiB
Dart

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/operations/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 {
static const routePath = "/trips";
final List<Trip> 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: routePath,
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
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<Trip>(),
fileName: extra["fileName"] as String,
fromStationSelection: extra.containsKey("station"),
operator: extra["operator"] as BRROperator? ?? BRROperator.arriva,
routeName: extra["routeName"] as String?,
);
},
);
@override
State<TripListPage> createState() => _TripListPageState();
}
class _TripListPageState extends State<TripListPage> {
late List<Trip> _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<void> _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<void> _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<void> _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<TripCard> createState() => _TripCardState();
}
class _TripCardState extends State<TripCard> {
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.busWorkNumber),
],
),
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<String> stations;
final Map<String, String> 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 = <String>[];
final seen = <String>{};
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<String, String>.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,
),
],
),
),
);
}
}