871 lines
29 KiB
Dart
871 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/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<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: "/trips",
|
|
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.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<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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|