Add initial project files and configurations for bus_running_record app
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../parsers/arriva_schedule_parser.dart";
|
||||
import "../parsers/stagecoach_schedule_parser.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
import "../services/storage_service.dart";
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/",
|
||||
builder: (context, state) => const HomePage(),
|
||||
);
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
bool _isUploading = false;
|
||||
String? _errorMessage;
|
||||
BRROperator _selectedOperator = BRROperator.arriva;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restoreSession();
|
||||
}
|
||||
|
||||
Future<void> _restoreSession() async {
|
||||
final state = await StorageService().loadState();
|
||||
if (state != null && state.trips.isNotEmpty && mounted) {
|
||||
context.go("/trips", extra: {
|
||||
"trips": state.trips,
|
||||
"fileName": state.scheduleFileName ?? "",
|
||||
"operator": _selectedOperator,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String get _fileExtension {
|
||||
switch (_selectedOperator) {
|
||||
case BRROperator.arriva:
|
||||
return "docx";
|
||||
case BRROperator.stagecoach:
|
||||
return "pdf";
|
||||
}
|
||||
}
|
||||
|
||||
String get _formatLabel {
|
||||
switch (_selectedOperator) {
|
||||
case BRROperator.arriva:
|
||||
return "Arriva schedule format (.docx)";
|
||||
case BRROperator.stagecoach:
|
||||
return "Stagecoach schedule format (.pdf)";
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadSchedule() async {
|
||||
print("Upload button pressed");
|
||||
setState(() {
|
||||
_isUploading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [_fileExtension],
|
||||
allowMultiple: false,
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
setState(() { _isUploading = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) throw Exception("Could not read file bytes");
|
||||
|
||||
String? routeName;
|
||||
final List<Trip> trips;
|
||||
if (_selectedOperator == BRROperator.stagecoach) {
|
||||
final parser = StagecoachScheduleParser();
|
||||
trips = await parser.parseBytes(file.bytes!);
|
||||
routeName = parser.parsedRouteName;
|
||||
} else {
|
||||
trips = await ArrivaScheduleParser().parseBytes(file.bytes!);
|
||||
}
|
||||
|
||||
if (trips.isEmpty) throw Exception("No trips found in schedule");
|
||||
|
||||
if (mounted) {
|
||||
context.go("/station-selection", extra: {
|
||||
"trips": trips,
|
||||
"fileName": file.name,
|
||||
"operator": _selectedOperator,
|
||||
if (routeName != null) "routeName": routeName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error: $e");
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() { _isUploading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
const Text(
|
||||
"BUS\nRUNNING\nRECORD",
|
||||
style: TextStyle(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.0,
|
||||
letterSpacing: -1,
|
||||
color: Color(0xFFEEEEEE),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Container(
|
||||
width: 40,
|
||||
height: 3,
|
||||
color: const Color(0xFF00A9CE),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// operator selector
|
||||
const Text(
|
||||
"OPERATOR",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF777777),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
_OperatorChip(
|
||||
label: "ARRIVA",
|
||||
selected: _selectedOperator == BRROperator.arriva,
|
||||
onTap: () => setState(() => _selectedOperator = BRROperator.arriva),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_OperatorChip(
|
||||
label: "STAGECOACH",
|
||||
selected: _selectedOperator == BRROperator.stagecoach,
|
||||
onTap: () => setState(() => _selectedOperator = BRROperator.stagecoach),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// format info
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF2E2E2E)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 16, color: Color(0xFF777777)),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_formatLabel,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF777777)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A1515),
|
||||
border: Border.all(color: const Color(0xFF7A2020)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCF6679),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isUploading ? null : _uploadSchedule,
|
||||
child: _isUploading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: const Text("LOAD SCHEDULE"),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OperatorChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OperatorChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF002A33) : const Color(0xFF1E1E1E),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF2E2E2E),
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF777777),
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "../models/trip.dart";
|
||||
import "../services/brr_export_service.dart";
|
||||
|
||||
class StationSelectionPage extends StatefulWidget {
|
||||
final List<Trip> trips;
|
||||
final String fileName;
|
||||
final BRROperator operator;
|
||||
final String? routeName;
|
||||
|
||||
const StationSelectionPage({
|
||||
super.key,
|
||||
required this.trips,
|
||||
required this.fileName,
|
||||
this.operator = BRROperator.arriva,
|
||||
this.routeName,
|
||||
});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/station-selection",
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
if (extra == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go("/");
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return StationSelectionPage(
|
||||
trips: (extra["trips"] as List).cast<Trip>(),
|
||||
fileName: extra["fileName"] as String,
|
||||
operator: extra["operator"] as BRROperator? ?? BRROperator.arriva,
|
||||
routeName: extra["routeName"] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
State<StationSelectionPage> createState() => _StationSelectionPageState();
|
||||
}
|
||||
|
||||
class _StationSelectionPageState extends State<StationSelectionPage> {
|
||||
String? _selectedStation;
|
||||
String? _selectedDirection;
|
||||
List<String> _availableStations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_extractStations();
|
||||
}
|
||||
|
||||
void _extractStations() {
|
||||
final stationsSet = <String>{};
|
||||
for (final trip in widget.trips) {
|
||||
stationsSet.addAll(trip.stationOrder);
|
||||
}
|
||||
_availableStations = stationsSet.toList()..sort();
|
||||
}
|
||||
|
||||
bool _isTerminus(String station) {
|
||||
for (final trip in widget.trips) {
|
||||
if (trip.stationOrder.isEmpty) continue;
|
||||
if (trip.stationOrder.first == station || trip.stationOrder.last == station) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// For a given station, find where buses are headed in each direction
|
||||
String? _getTerminus(String station, String direction) {
|
||||
for (final trip in widget.trips) {
|
||||
if (!trip.stationOrder.contains(station)) continue;
|
||||
if (trip.stationOrder.isEmpty) continue;
|
||||
|
||||
// "departing" = outbound trips, show where they end up
|
||||
if (direction == "departing" && trip.direction == "outbound") {
|
||||
return trip.stationOrder.last;
|
||||
}
|
||||
|
||||
// "arriving" = inbound trips, show where they end up (opposite terminus)
|
||||
if (direction == "arriving" && trip.direction == "inbound") {
|
||||
return trip.stationOrder.last;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getDirectionLabel(String direction) {
|
||||
if (_selectedStation == null) {
|
||||
return direction == "departing" ? "DEPARTING" : "ARRIVING";
|
||||
}
|
||||
|
||||
if (_isTerminus(_selectedStation!)) {
|
||||
return direction == "departing" ? "DEPARTURES" : "ARRIVALS";
|
||||
}
|
||||
|
||||
final terminus = _getTerminus(_selectedStation!, direction);
|
||||
return "towards\n${terminus ?? "..."}";
|
||||
}
|
||||
|
||||
void _onContinue() {
|
||||
if (_selectedStation == null || _selectedDirection == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Select both a station and direction")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final filteredTrips = _filterAndUpdateTrips();
|
||||
|
||||
if (filteredTrips.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("No trips found for that selection")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.go("/trips", extra: {
|
||||
"trips": filteredTrips,
|
||||
"fileName": widget.fileName,
|
||||
"station": _selectedStation,
|
||||
"direction": _selectedDirection,
|
||||
"operator": widget.operator,
|
||||
if (widget.routeName != null) "routeName": widget.routeName,
|
||||
});
|
||||
}
|
||||
|
||||
List<Trip> _filterAndUpdateTrips() {
|
||||
final filtered = <Trip>[];
|
||||
final isTerminusStation = _isTerminus(_selectedStation!);
|
||||
|
||||
for (final trip in widget.trips) {
|
||||
if (!trip.stationTimes.containsKey(_selectedStation)) continue;
|
||||
|
||||
if (_selectedDirection != "both") {
|
||||
// For terminus stations, the direction logic flips:
|
||||
// "departing" from a terminus that's first on inbound = show inbound trips
|
||||
// "arriving" at a terminus thats last on outbound = show outbound trips
|
||||
final isFirstStation = trip.stationOrder.isNotEmpty && trip.stationOrder.first == _selectedStation;
|
||||
final isLastStation = trip.stationOrder.isNotEmpty && trip.stationOrder.last == _selectedStation;
|
||||
|
||||
if (isTerminusStation) {
|
||||
if (_selectedDirection == "departing" && !isFirstStation) continue;
|
||||
if (_selectedDirection == "arriving" && !isLastStation) continue;
|
||||
} else {
|
||||
if (_selectedDirection == "departing" && trip.direction != "outbound") continue;
|
||||
if (_selectedDirection == "arriving" && trip.direction != "inbound") continue;
|
||||
}
|
||||
}
|
||||
|
||||
final stationTime = trip.stationTimes[_selectedStation];
|
||||
if (stationTime != null) {
|
||||
filtered.add(trip.copyWith(scheduledTime: stationTime));
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go("/"),
|
||||
),
|
||||
title: const Text("SELECT STATION"),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// file info strip
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.description_outlined, size: 14, color: Color(0xFF777777)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.fileName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF777777),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${widget.trips.length} trips",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF00A9CE),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
const Text(
|
||||
"STATION",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF777777),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedStation,
|
||||
dropdownColor: const Color(0xFF252525),
|
||||
style: const TextStyle(color: Color(0xFFEEEEEE), fontSize: 15),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Choose station...",
|
||||
),
|
||||
items: _availableStations.map((station) {
|
||||
return DropdownMenuItem(
|
||||
value: station,
|
||||
child: Text(station),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) => setState(() => _selectedStation = value),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text(
|
||||
"DIRECTION",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF777777),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
_DirButton(
|
||||
label: _getDirectionLabel("departing"),
|
||||
selected: _selectedDirection == "departing",
|
||||
onTap: () => setState(() => _selectedDirection = "departing"),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_DirButton(
|
||||
label: _getDirectionLabel("arriving"),
|
||||
selected: _selectedDirection == "arriving",
|
||||
onTap: () => setState(() => _selectedDirection = "arriving"),
|
||||
),
|
||||
if (widget.operator == BRROperator.stagecoach) ...[
|
||||
const SizedBox(width: 10),
|
||||
_DirButton(
|
||||
label: "BOTH",
|
||||
selected: _selectedDirection == "both",
|
||||
onTap: () => setState(() => _selectedDirection = "both"),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _onContinue,
|
||||
child: const Text("VIEW TRIPS"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirButton extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DirButton({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF002A33) : const Color(0xFF1E1E1E),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF2E2E2E),
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected ? const Color(0xFF00A9CE) : const Color(0xFF777777),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user