QoL changes
All checks were successful
All checks were successful
This commit is contained in:
@@ -7,15 +7,24 @@ import 'package:mileograph_flutter/services/authservice.dart';
|
||||
import 'package:mileograph_flutter/services/dataService.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Dashboard extends StatelessWidget {
|
||||
class Dashboard extends StatefulWidget {
|
||||
const Dashboard({super.key});
|
||||
|
||||
@override
|
||||
State<Dashboard> createState() => _DashboardState();
|
||||
}
|
||||
|
||||
class _DashboardState extends State<Dashboard> {
|
||||
bool _showAllOnThisDay = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
final auth = context.watch<AuthService>();
|
||||
final stats = data.homepageStats;
|
||||
|
||||
final isInitialLoading = data.isHomepageLoading || stats == null;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await data.fetchHomepageStats();
|
||||
@@ -34,32 +43,53 @@ class Dashboard extends StatelessWidget {
|
||||
currentYearMileage: data.getMileageForCurrentYear(),
|
||||
trips: data.trips.length,
|
||||
);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Stack(
|
||||
children: [
|
||||
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
||||
const SizedBox(height: 16),
|
||||
isWide
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildMainColumn(context, data)),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: _buildSidebar(context, data),
|
||||
ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
||||
const SizedBox(height: 16),
|
||||
isWide
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildMainColumn(context, data)),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: _buildSidebar(context, data),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildMainColumn(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildSidebar(context, data),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildMainColumn(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildSidebar(context, data),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isInitialLoading)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.7),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 12),
|
||||
Text('Loading dashboard data...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -153,6 +183,17 @@ class Dashboard extends StatelessWidget {
|
||||
_buildCard(
|
||||
context,
|
||||
title: 'On this day',
|
||||
action: data.onThisDay
|
||||
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
||||
.length >
|
||||
5
|
||||
? TextButton(
|
||||
onPressed: () => setState(() {
|
||||
_showAllOnThisDay = !_showAllOnThisDay;
|
||||
}),
|
||||
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
|
||||
)
|
||||
: null,
|
||||
trailing: data.isOnThisDayLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
@@ -163,6 +204,7 @@ class Dashboard extends StatelessWidget {
|
||||
child: _buildLegList(
|
||||
context,
|
||||
data.onThisDay,
|
||||
showAll: _showAllOnThisDay,
|
||||
emptyMessage: 'No historical moves for today yet.',
|
||||
),
|
||||
),
|
||||
@@ -231,12 +273,17 @@ class Dashboard extends StatelessWidget {
|
||||
BuildContext context,
|
||||
List<Leg> legs, {
|
||||
required String emptyMessage,
|
||||
bool showAll = false,
|
||||
}) {
|
||||
if (legs.isEmpty) {
|
||||
final filtered = legs
|
||||
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
||||
.toList();
|
||||
if (filtered.isEmpty) {
|
||||
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
|
||||
}
|
||||
final toShow = showAll ? filtered : filtered.take(5).toList();
|
||||
return Column(
|
||||
children: legs.take(5).map((leg) {
|
||||
children: toShow.map((leg) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/dataService.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -155,25 +158,6 @@ class _LegsPageState extends State<LegsPage> {
|
||||
runSpacing: 12,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
icon: Icon(Icons.south),
|
||||
label: Text('Newest first'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
icon: Icon(Icons.north),
|
||||
label: Text('Oldest first'),
|
||||
),
|
||||
],
|
||||
selected: {_sortDirection},
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() => _sortDirection = selection.first);
|
||||
_refreshLegs();
|
||||
},
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _pickDate(start: true),
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
@@ -229,37 +213,7 @@ class _LegsPageState extends State<LegsPage> {
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
...legs.map((leg) => Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.train),
|
||||
title: Text('${leg.start} → ${leg.end}'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_formatDateTime(leg.beginTime)),
|
||||
if (leg.headcode.isNotEmpty)
|
||||
Text('Headcode: ${leg.headcode}'),
|
||||
if (leg.route.isNotEmpty)
|
||||
Text(
|
||||
leg.route,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('${leg.mileage.toStringAsFixed(1)} mi'),
|
||||
Text(
|
||||
leg.network,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
),
|
||||
)),
|
||||
...legs.map((leg) => _buildLegCard(context, leg)),
|
||||
const SizedBox(height: 8),
|
||||
if (data.legsHasMore || data.isLegsLoading)
|
||||
Align(
|
||||
@@ -297,4 +251,148 @@ class _LegsPageState extends State<LegsPage> {
|
||||
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
return '$dateStr · $timeStr';
|
||||
}
|
||||
|
||||
Widget _buildLegCard(BuildContext context, Leg leg) {
|
||||
final routeSegments = _parseRouteSegments(leg.route);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: const Icon(Icons.train),
|
||||
title: Text('${leg.start} → ${leg.end}'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_formatDateTime(leg.beginTime)),
|
||||
if (leg.headcode.isNotEmpty)
|
||||
Text(
|
||||
'Headcode: ${leg.headcode}',
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
if (leg.network.isNotEmpty)
|
||||
Text(
|
||||
leg.network,
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${leg.mileage.toStringAsFixed(1)} mi',
|
||||
style:
|
||||
textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (leg.tripId != 0)
|
||||
Text(
|
||||
'Trip #${leg.tripId}',
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leg.notes.isNotEmpty) ...[
|
||||
Text('Notes', style: textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(leg.notes),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (leg.locos.isNotEmpty) ...[
|
||||
Text('Locos', style: textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _buildLocoChips(context, leg),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (routeSegments.isNotEmpty) ...[
|
||||
Text('Route', style: textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
_buildRouteList(routeSegments),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
|
||||
final theme = Theme.of(context);
|
||||
return leg.locos
|
||||
.map(
|
||||
(loco) => Chip(
|
||||
label: Text('${loco.locoClass} ${loco.number}'),
|
||||
avatar: const Icon(Icons.directions_railway, size: 16),
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget _buildRouteList(List<String> segments) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: segments
|
||||
.map(
|
||||
(segment) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.circle, size: 10),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(segment)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _parseRouteSegments(String route) {
|
||||
final trimmed = route.trim();
|
||||
if (trimmed.isEmpty) return [];
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore and try alternative parsing
|
||||
}
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
final replaced = trimmed.replaceAll("'", '"');
|
||||
final decoded = jsonDecode(replaced);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (trimmed.contains('->')) {
|
||||
return trimmed
|
||||
.split('->')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
if (trimmed.contains(',')) {
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/apiService.dart';
|
||||
import 'package:mileograph_flutter/services/dataService.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class NewEntryPage extends StatefulWidget {
|
||||
const NewEntryPage({super.key});
|
||||
@@ -17,6 +19,8 @@ class NewEntryPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NewEntryPageState extends State<NewEntryPage> {
|
||||
static const _draftPrefsKey = 'new_entry_draft';
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
TimeOfDay _selectedTime = TimeOfDay.now();
|
||||
@@ -31,20 +35,42 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
RouteResult? _routeResult;
|
||||
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
||||
int? _selectedTripId;
|
||||
bool _restoringDraft = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final controller in [
|
||||
_startController,
|
||||
_endController,
|
||||
_headcodeController,
|
||||
_notesController,
|
||||
_mileageController,
|
||||
_networkController,
|
||||
]) {
|
||||
controller.addListener(_saveDraft);
|
||||
}
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final data = context.read<DataService>();
|
||||
data.fetchClassList();
|
||||
data.fetchTrips();
|
||||
_loadDraft();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in [
|
||||
_startController,
|
||||
_endController,
|
||||
_headcodeController,
|
||||
_notesController,
|
||||
_mileageController,
|
||||
_networkController,
|
||||
]) {
|
||||
controller.removeListener(_saveDraft);
|
||||
}
|
||||
_startController.dispose();
|
||||
_endController.dispose();
|
||||
_headcodeController.dispose();
|
||||
@@ -73,7 +99,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
DropdownMenuItem(value: t.tripId, child: Text(t.tripName)),
|
||||
),
|
||||
],
|
||||
onChanged: (val) => setState(() => _selectedTripId = val),
|
||||
onChanged: (val) {
|
||||
setState(() => _selectedTripId = val);
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -126,6 +155,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
: TripSummary(tripId: 0, tripName: result, tripMileage: 0),
|
||||
);
|
||||
setState(() => _selectedTripId = match.tripId);
|
||||
_saveDraft();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
@@ -149,6 +179,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
_mileageController.text = result.distance.toStringAsFixed(2);
|
||||
_useManualMileage = false;
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +214,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
);
|
||||
}
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -197,6 +229,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => _selectedDate = picked);
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _pickTime() async {
|
||||
@@ -204,7 +237,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
context: context,
|
||||
initialTime: _selectedTime,
|
||||
);
|
||||
if (picked != null) setState(() => _selectedTime = picked);
|
||||
if (picked != null) {
|
||||
setState(() => _selectedTime = picked);
|
||||
_saveDraft();
|
||||
}
|
||||
}
|
||||
|
||||
DateTime get _legDateTime => DateTime(
|
||||
@@ -295,7 +331,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Entry submitted')));
|
||||
_formKey.currentState!.reset();
|
||||
_resetFormState(clearDraft: true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -306,6 +342,166 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetFormState({bool clearDraft = false}) async {
|
||||
_formKey.currentState?.reset();
|
||||
_startController.clear();
|
||||
_endController.clear();
|
||||
_headcodeController.clear();
|
||||
_notesController.clear();
|
||||
_mileageController.clear();
|
||||
_networkController.clear();
|
||||
setState(() {
|
||||
_selectedDate = DateTime.now();
|
||||
_selectedTime = TimeOfDay.now();
|
||||
_useManualMileage = false;
|
||||
_routeResult = null;
|
||||
_tractionItems
|
||||
..clear()
|
||||
..add(_TractionItem.marker());
|
||||
_selectedTripId = null;
|
||||
_submitting = false;
|
||||
});
|
||||
if (clearDraft) {
|
||||
await _clearDraft();
|
||||
} else {
|
||||
_saveDraft();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDraft() async {
|
||||
if (_restoringDraft) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final draft = {
|
||||
"date": _selectedDate.toIso8601String(),
|
||||
"time": {"hour": _selectedTime.hour, "minute": _selectedTime.minute},
|
||||
"start": _startController.text,
|
||||
"end": _endController.text,
|
||||
"headcode": _headcodeController.text,
|
||||
"notes": _notesController.text,
|
||||
"mileage": _mileageController.text,
|
||||
"network": _networkController.text,
|
||||
"useManualMileage": _useManualMileage,
|
||||
"selectedTripId": _selectedTripId,
|
||||
"routeResult": _routeResult == null
|
||||
? null
|
||||
: {
|
||||
"input_route": _routeResult!.inputRoute,
|
||||
"calculated_route": _routeResult!.calculatedRoute,
|
||||
"costs": _routeResult!.costs,
|
||||
"distance": _routeResult!.distance,
|
||||
},
|
||||
"tractionItems": _serializeTractionItems(),
|
||||
};
|
||||
await prefs.setString(_draftPrefsKey, jsonEncode(draft));
|
||||
}
|
||||
|
||||
Future<void> _clearDraft() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_draftPrefsKey);
|
||||
}
|
||||
|
||||
Future<void> _loadDraft() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_draftPrefsKey);
|
||||
if (raw == null) return;
|
||||
try {
|
||||
final data = jsonDecode(raw);
|
||||
if (data is! Map) return;
|
||||
_restoringDraft = true;
|
||||
setState(() {
|
||||
if (data['date'] is String) {
|
||||
_selectedDate = DateTime.tryParse(data['date']) ?? _selectedDate;
|
||||
}
|
||||
if (data['time'] is Map) {
|
||||
final time = data['time'] as Map;
|
||||
final hour = time['hour'] as int?;
|
||||
final minute = time['minute'] as int?;
|
||||
if (hour != null && minute != null) {
|
||||
_selectedTime = TimeOfDay(hour: hour, minute: minute);
|
||||
}
|
||||
}
|
||||
_useManualMileage = data['useManualMileage'] ?? _useManualMileage;
|
||||
_selectedTripId = data['selectedTripId'];
|
||||
if (data['routeResult'] is Map<String, dynamic>) {
|
||||
_routeResult =
|
||||
RouteResult.fromJson(Map<String, dynamic>.from(data['routeResult']));
|
||||
_mileageController.text = _routeResult!.distance.toStringAsFixed(2);
|
||||
}
|
||||
if (data['tractionItems'] is List) {
|
||||
_restoreTractionItems(List<Map<String, dynamic>>.from(
|
||||
data['tractionItems'].cast<Map>(),
|
||||
));
|
||||
}
|
||||
});
|
||||
_startController.text = data['start'] ?? '';
|
||||
_endController.text = data['end'] ?? '';
|
||||
_headcodeController.text = data['headcode'] ?? '';
|
||||
_notesController.text = data['notes'] ?? '';
|
||||
_mileageController.text = data['mileage'] ?? '';
|
||||
_networkController.text = data['network'] ?? '';
|
||||
} catch (_) {
|
||||
// Ignore corrupt draft data
|
||||
} finally {
|
||||
_restoringDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _serializeTractionItems() {
|
||||
return _tractionItems
|
||||
.map(
|
||||
(item) => {
|
||||
"isMarker": item.isMarker,
|
||||
"powering": item.powering,
|
||||
"loco": item.loco == null
|
||||
? null
|
||||
: {
|
||||
"id": item.loco!.id,
|
||||
"type": item.loco!.type,
|
||||
"number": item.loco!.number,
|
||||
"class": item.loco!.locoClass,
|
||||
"name": item.loco!.name,
|
||||
"operator": item.loco!.operator,
|
||||
"notes": item.loco!.notes,
|
||||
"evn": item.loco!.evn,
|
||||
},
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _restoreTractionItems(List<Map<String, dynamic>> items) {
|
||||
final restored = <_TractionItem>[];
|
||||
for (final item in items) {
|
||||
final locoData = item['loco'] as Map<String, dynamic>?;
|
||||
LocoSummary? loco;
|
||||
if (locoData != null) {
|
||||
loco = LocoSummary(
|
||||
locoId: locoData['id'] ?? 0,
|
||||
locoType: locoData['type'] ?? '',
|
||||
locoNumber: locoData['number'] ?? '',
|
||||
locoName: locoData['name'] ?? '',
|
||||
locoClass: locoData['class'] ?? '',
|
||||
locoOperator: locoData['operator'] ?? '',
|
||||
locoNotes: locoData['notes'],
|
||||
locoEvn: locoData['evn'],
|
||||
);
|
||||
}
|
||||
restored.add(
|
||||
_TractionItem(
|
||||
loco: loco,
|
||||
powering: item['powering'] ?? true,
|
||||
isMarker: item['isMarker'] ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (restored.where((e) => e.isMarker).isEmpty) {
|
||||
restored.insert(0, _TractionItem.marker());
|
||||
}
|
||||
_tractionItems
|
||||
..clear()
|
||||
..addAll(restored);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||
@@ -413,6 +609,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
setState(() {
|
||||
_useManualMileage = val;
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
if (_useManualMileage)
|
||||
@@ -506,6 +703,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
final item = _tractionItems.removeAt(oldIndex);
|
||||
_tractionItems.insert(newIndex, item);
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
itemCount: _tractionItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -549,6 +747,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
setState(() {
|
||||
_tractionItems[index] = item.copyWith(powering: v);
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -557,6 +756,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
setState(() {
|
||||
_tractionItems.removeAt(index);
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -23,22 +23,15 @@ class _TractionPageState extends State<TractionPage> {
|
||||
final _classController = TextEditingController();
|
||||
final _classFocusNode = FocusNode();
|
||||
final _numberController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
bool _mileageFirst = true;
|
||||
bool _initialised = false;
|
||||
bool _showAdvancedFilters = false;
|
||||
String? _selectedClass;
|
||||
late Set<String> _selectedKeys;
|
||||
final _nameController = TextEditingController();
|
||||
final _operatorController = TextEditingController();
|
||||
final _statusController = TextEditingController();
|
||||
final _evnController = TextEditingController();
|
||||
final _ownerController = TextEditingController();
|
||||
final _locationController = TextEditingController();
|
||||
final _liveryController = TextEditingController();
|
||||
final _domainController = TextEditingController();
|
||||
final _typeController = TextEditingController();
|
||||
|
||||
int offset = 0;
|
||||
final Map<String, TextEditingController> _dynamicControllers = {};
|
||||
final Map<String, String?> _enumSelections = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -53,7 +46,9 @@ class _TractionPageState extends State<TractionPage> {
|
||||
_initialised = true;
|
||||
_selectedKeys = {...widget.selectedKeys};
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<DataService>().fetchClassList();
|
||||
final data = context.read<DataService>();
|
||||
data.fetchClassList();
|
||||
data.fetchEventFields();
|
||||
_refreshTraction();
|
||||
});
|
||||
}
|
||||
@@ -66,47 +61,41 @@ class _TractionPageState extends State<TractionPage> {
|
||||
_classFocusNode.dispose();
|
||||
_numberController.dispose();
|
||||
_nameController.dispose();
|
||||
_operatorController.dispose();
|
||||
_statusController.dispose();
|
||||
_evnController.dispose();
|
||||
_ownerController.dispose();
|
||||
_locationController.dispose();
|
||||
_liveryController.dispose();
|
||||
_domainController.dispose();
|
||||
_typeController.dispose();
|
||||
for (final controller in _dynamicControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _hasFilters {
|
||||
final dynamicFieldsUsed = _dynamicControllers.values
|
||||
.any((controller) => controller.text.trim().isNotEmpty) ||
|
||||
_enumSelections.values
|
||||
.any((value) => (value ?? '').toString().trim().isNotEmpty);
|
||||
|
||||
return [
|
||||
_selectedClass,
|
||||
_classController.text,
|
||||
_numberController.text,
|
||||
_nameController.text,
|
||||
_operatorController.text,
|
||||
_statusController.text,
|
||||
_evnController.text,
|
||||
_ownerController.text,
|
||||
_locationController.text,
|
||||
_liveryController.text,
|
||||
_domainController.text,
|
||||
_typeController.text,
|
||||
].any((value) => (value ?? '').toString().trim().isNotEmpty);
|
||||
].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
|
||||
dynamicFieldsUsed;
|
||||
}
|
||||
|
||||
Future<void> _refreshTraction({bool append = false}) async {
|
||||
final data = context.read<DataService>();
|
||||
final filters = {
|
||||
"name": _nameController.text.trim(),
|
||||
"operator": _operatorController.text.trim(),
|
||||
"status": _statusController.text.trim(),
|
||||
"evn": _evnController.text.trim(),
|
||||
"owner": _ownerController.text.trim(),
|
||||
"location": _locationController.text.trim(),
|
||||
"livery": _liveryController.text.trim(),
|
||||
"domain": _domainController.text.trim(),
|
||||
"type": _typeController.text.trim(),
|
||||
}..removeWhere((key, value) => value.isEmpty);
|
||||
final filters = <String, dynamic>{};
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isNotEmpty) filters['name'] = name;
|
||||
_dynamicControllers.forEach((key, controller) {
|
||||
final value = controller.text.trim();
|
||||
if (value.isNotEmpty) filters[key] = value;
|
||||
});
|
||||
_enumSelections.forEach((key, value) {
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
filters[key] = value;
|
||||
}
|
||||
});
|
||||
final hadOnly = !_hasFilters;
|
||||
await data.fetchTraction(
|
||||
hadOnly: hadOnly,
|
||||
@@ -120,21 +109,13 @@ class _TractionPageState extends State<TractionPage> {
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
for (final controller in [
|
||||
_classController,
|
||||
_numberController,
|
||||
_nameController,
|
||||
_operatorController,
|
||||
_statusController,
|
||||
_evnController,
|
||||
_ownerController,
|
||||
_locationController,
|
||||
_liveryController,
|
||||
_domainController,
|
||||
_typeController,
|
||||
]) {
|
||||
for (final controller in [_classController, _numberController, _nameController]) {
|
||||
controller.clear();
|
||||
}
|
||||
for (final controller in _dynamicControllers.values) {
|
||||
controller.clear();
|
||||
}
|
||||
_enumSelections.clear();
|
||||
setState(() {
|
||||
_selectedClass = null;
|
||||
_mileageFirst = true;
|
||||
@@ -151,12 +132,34 @@ class _TractionPageState extends State<TractionPage> {
|
||||
}
|
||||
}
|
||||
|
||||
List<EventField> _activeEventFields(List<EventField> fields) {
|
||||
return fields
|
||||
.where(
|
||||
(field) =>
|
||||
!['class', 'number', 'name', 'build date', 'build_date']
|
||||
.contains(field.name.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _ensureControllersForFields(List<EventField> fields) {
|
||||
for (final field in fields) {
|
||||
if (field.enumValues != null) {
|
||||
_enumSelections.putIfAbsent(field.name, () => null);
|
||||
} else {
|
||||
_dynamicControllers.putIfAbsent(field.name, () => TextEditingController());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
final traction = data.traction;
|
||||
final classOptions = data.locoClasses;
|
||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||
_ensureControllersForFields(data.eventFields);
|
||||
final extraFields = _activeEventFields(data.eventFields);
|
||||
|
||||
final listView = RefreshIndicator(
|
||||
onRefresh: _refreshTraction,
|
||||
@@ -225,22 +228,17 @@ class _TractionPageState extends State<TractionPage> {
|
||||
);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(
|
||||
context,
|
||||
controller,
|
||||
focusNode,
|
||||
onFieldSubmitted,
|
||||
) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Class',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
);
|
||||
},
|
||||
(context, controller, focusNode, onFieldSubmitted) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Class',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
final optionList = options.toList();
|
||||
if (optionList.isEmpty) {
|
||||
@@ -325,9 +323,7 @@ class _TractionPageState extends State<TractionPage> {
|
||||
: Icons.expand_more,
|
||||
),
|
||||
label: Text(
|
||||
_showAdvancedFilters
|
||||
? 'Hide filters'
|
||||
: 'More filters',
|
||||
_showAdvancedFilters ? 'Hide filters' : 'More filters',
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
@@ -344,100 +340,28 @@ class _TractionPageState extends State<TractionPage> {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _operatorController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Operator',
|
||||
border: OutlineInputBorder(),
|
||||
child: data.isEventFieldsLoading
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _statusController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _evnController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'EVN',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _ownerController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Owner',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _liveryController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Livery',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _domainController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Domain',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _typeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: extraFields.isEmpty
|
||||
? const Text('No extra filters available right now.')
|
||||
: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: extraFields
|
||||
.map(
|
||||
(field) => _buildFilterInput(
|
||||
context,
|
||||
field,
|
||||
isMobile,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox.shrink(),
|
||||
),
|
||||
@@ -480,9 +404,8 @@ class _TractionPageState extends State<TractionPage> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: data.isTractionLoading
|
||||
? null
|
||||
: () => _refreshTraction(append: true),
|
||||
onPressed:
|
||||
data.isTractionLoading ? null : () => _refreshTraction(append: true),
|
||||
icon: data.isTractionLoading
|
||||
? const SizedBox(
|
||||
height: 14,
|
||||
@@ -535,6 +458,7 @@ class _TractionPageState extends State<TractionPage> {
|
||||
final status = loco.status ?? 'Unknown';
|
||||
final operatorName = loco.operator ?? '';
|
||||
final domain = loco.domain ?? '';
|
||||
final statusColors = _statusChipColors(context, status);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
@@ -564,9 +488,8 @@ class _TractionPageState extends State<TractionPage> {
|
||||
),
|
||||
Chip(
|
||||
label: Text(status),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
backgroundColor: statusColors.$1,
|
||||
labelStyle: TextStyle(color: statusColors.$2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -654,6 +577,44 @@ class _TractionPageState extends State<TractionPage> {
|
||||
);
|
||||
}
|
||||
|
||||
(Color, Color) _statusChipColors(BuildContext context, String status) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isDark = scheme.brightness == Brightness.dark;
|
||||
Color blend(Color base, {double bgOpacity = 0.18, double fgOpacity = 0.82}) {
|
||||
final bg = Color.alphaBlend(
|
||||
base.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity),
|
||||
scheme.surface,
|
||||
);
|
||||
final fg = Color.alphaBlend(
|
||||
base.withOpacity(isDark ? fgOpacity : fgOpacity * 0.8),
|
||||
scheme.onSurface,
|
||||
);
|
||||
return Color.lerp(bg, fg, 0.0) ?? bg;
|
||||
}
|
||||
|
||||
Color background;
|
||||
Color foreground;
|
||||
final key = status.toLowerCase();
|
||||
|
||||
if (key.contains('scrap')) {
|
||||
background = blend(Colors.red);
|
||||
foreground = Colors.red.shade200.withOpacity(isDark ? 0.85 : 0.9);
|
||||
} else if (key.contains('active')) {
|
||||
background = blend(scheme.primary);
|
||||
foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8);
|
||||
} else if (key.contains('withdrawn')) {
|
||||
background = blend(Colors.amber);
|
||||
foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8);
|
||||
} else if (key.contains('stored') || key.contains('unknown')) {
|
||||
background = blend(Colors.grey);
|
||||
foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75);
|
||||
} else {
|
||||
background = scheme.surfaceContainerHighest;
|
||||
foreground = scheme.onSurface;
|
||||
}
|
||||
return (background, foreground);
|
||||
}
|
||||
|
||||
Future<void> _showLocoInfo(LocoSummary loco) async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -750,4 +711,70 @@ class _TractionPageState extends State<TractionPage> {
|
||||
if (value == null) return '0';
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
Widget _buildFilterInput(
|
||||
BuildContext context,
|
||||
EventField field,
|
||||
bool isMobile,
|
||||
) {
|
||||
final width = isMobile ? double.infinity : 220.0;
|
||||
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
|
||||
final options = field.enumValues!.map((e) => e.toString()).toSet().toList();
|
||||
final currentValue = _enumSelections[field.name];
|
||||
if (currentValue != null && !options.contains(currentValue)) {
|
||||
options.insert(0, currentValue);
|
||||
}
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: DropdownButtonFormField<String?>(
|
||||
value: currentValue,
|
||||
decoration: InputDecoration(
|
||||
labelText: field.display,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Any')),
|
||||
...options
|
||||
.map(
|
||||
(value) => DropdownMenuItem(
|
||||
value: value,
|
||||
child: Text(value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_enumSelections[field.name] = val;
|
||||
});
|
||||
_refreshTraction();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final controller =
|
||||
_dynamicControllers[field.name] ?? TextEditingController();
|
||||
_dynamicControllers[field.name] = controller;
|
||||
TextInputType? inputType;
|
||||
if (field.type != null) {
|
||||
final type = field.type!.toLowerCase();
|
||||
if (type.contains('int') || type.contains('num') || type.contains('double')) {
|
||||
inputType = const TextInputType.numberWithOptions(decimal: true);
|
||||
}
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: inputType,
|
||||
decoration: InputDecoration(
|
||||
labelText: field.display,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,51 +269,85 @@ class _TripsPageState extends State<TripsPage> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) {
|
||||
final data = context.read<DataService>();
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: FutureBuilder<List<TripLocoStat>>(
|
||||
future: data.fetchTripLocoStats(trip.id),
|
||||
builder: (ctx, snapshot) {
|
||||
final items = snapshot.data ?? [];
|
||||
final loading =
|
||||
snapshot.connectionState == ConnectionState.waiting;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Text(
|
||||
trip.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: ListView.builder(
|
||||
itemCount: trip.legs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final leg = trip.legs[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.train),
|
||||
title: Text('${leg.start} → ${leg.end}'),
|
||||
subtitle: Text(_formatDate(leg.beginTime)),
|
||||
trailing: Text(
|
||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Text(
|
||||
trip.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (loading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text('No traction recorded for this trip yet.'),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final loco = items[index];
|
||||
final won = loco.won;
|
||||
final isWon = won == true;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.train),
|
||||
title: Text('${loco.locoClass} ${loco.number}'),
|
||||
subtitle:
|
||||
loco.name == null || loco.name!.isEmpty
|
||||
? null
|
||||
: Text(loco.name!),
|
||||
trailing: Chip(
|
||||
label: Text(isWon ? 'Won' : 'Dud'),
|
||||
backgroundColor: isWon
|
||||
? Colors.green.shade100
|
||||
: Colors.grey.shade300,
|
||||
labelStyle: TextStyle(
|
||||
color: isWon
|
||||
? Colors.green.shade900
|
||||
: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -188,18 +188,19 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
if (!_fetched) {
|
||||
_fetched = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future(() async {
|
||||
final data = context.read<DataService>();
|
||||
final auth = context.read<AuthService>();
|
||||
api.setTokenProvider(() => auth.token);
|
||||
await auth.tryRestoreSession();
|
||||
if (!auth.isLoggedIn) return;
|
||||
if (data.homepageStats == null) {
|
||||
data.fetchHomepageStats();
|
||||
}
|
||||
if (data.legs.isEmpty) {
|
||||
data.fetchLegs();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future(() async {
|
||||
final data = context.read<DataService>();
|
||||
final auth = context.read<AuthService>();
|
||||
api.setTokenProvider(() => auth.token);
|
||||
await auth.tryRestoreSession();
|
||||
if (!auth.isLoggedIn) return;
|
||||
data.fetchEventFields();
|
||||
if (data.homepageStats == null) {
|
||||
data.fetchHomepageStats();
|
||||
}
|
||||
if (data.legs.isEmpty) {
|
||||
data.fetchLegs();
|
||||
}
|
||||
if (data.traction.isEmpty) {
|
||||
data.fetchHadTraction();
|
||||
|
||||
@@ -397,3 +397,56 @@ class TripDetail {
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
class TripLocoStat {
|
||||
final String locoClass;
|
||||
final String number;
|
||||
final String? name;
|
||||
final bool won;
|
||||
|
||||
TripLocoStat({
|
||||
required this.locoClass,
|
||||
required this.number,
|
||||
required this.won,
|
||||
this.name,
|
||||
});
|
||||
|
||||
factory TripLocoStat.fromJson(Map<String, dynamic> json) => TripLocoStat(
|
||||
locoClass: json['loco_class'] ?? json['class'] ?? '',
|
||||
number: json['loco_number'] ?? json['number'] ?? '',
|
||||
name: json['loco_name'] ?? json['name'],
|
||||
won: json['won'] == 1 ||
|
||||
json['won'] == true ||
|
||||
(json['won'] is String && json['won'].toString() == '1'),
|
||||
);
|
||||
}
|
||||
|
||||
class EventField {
|
||||
final String name;
|
||||
final String display;
|
||||
final String? type;
|
||||
final List<String>? enumValues;
|
||||
|
||||
const EventField({
|
||||
required this.name,
|
||||
required this.display,
|
||||
this.type,
|
||||
this.enumValues,
|
||||
});
|
||||
|
||||
factory EventField.fromJson(Map<String, dynamic> json) {
|
||||
final enumList = json['enum'];
|
||||
List<String>? enumValues;
|
||||
if (enumList is List) {
|
||||
enumValues = enumList.map((e) => e.toString()).toList();
|
||||
}
|
||||
final baseName = json['name']?.toString() ?? json['field']?.toString() ?? '';
|
||||
final display = json['field']?.toString() ?? baseName;
|
||||
return EventField(
|
||||
name: baseName,
|
||||
display: display,
|
||||
type: json['type']?.toString(),
|
||||
enumValues: enumValues,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/apiService.dart';
|
||||
import 'package:mileograph_flutter/services/tokenStorageService.dart';
|
||||
|
||||
class AuthService extends ChangeNotifier {
|
||||
final ApiService api;
|
||||
static const _tokenKey = 'auth_token';
|
||||
bool _restoring = false;
|
||||
|
||||
// secure storage instance
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
final TokenStorageService _tokenStorage = TokenStorageService();
|
||||
|
||||
AuthService({required this.api});
|
||||
|
||||
@@ -74,10 +72,10 @@ class AuthService extends ChangeNotifier {
|
||||
|
||||
Future<void> tryRestoreSession() async {
|
||||
if (_restoring || _user != null) return;
|
||||
_restoring = true;
|
||||
try {
|
||||
// read token from secure storage
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
_restoring = true;
|
||||
try {
|
||||
// read token from secure storage (with fallback)
|
||||
final token = await _tokenStorage.getToken();
|
||||
if (token == null || token.isEmpty) return;
|
||||
|
||||
final userResponse = await api.get(
|
||||
@@ -103,11 +101,11 @@ class AuthService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _persistToken(String token) async {
|
||||
await _storage.write(key: _tokenKey, value: token);
|
||||
await _tokenStorage.setToken(token);
|
||||
}
|
||||
|
||||
Future<void> _clearToken() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _tokenStorage.clearToken();
|
||||
}
|
||||
|
||||
Future<void> register({
|
||||
|
||||
@@ -61,6 +61,10 @@ class DataService extends ChangeNotifier {
|
||||
List<String> get locoClasses => _locoClasses;
|
||||
List<TripSummary> _tripList = [];
|
||||
List<TripSummary> get tripList => _tripList;
|
||||
List<EventField> _eventFields = [];
|
||||
List<EventField> get eventFields => _eventFields;
|
||||
bool _isEventFieldsLoading = false;
|
||||
bool get isEventFieldsLoading => _isEventFieldsLoading;
|
||||
|
||||
// Station Data
|
||||
List<Station>? _cachedStations;
|
||||
@@ -73,6 +77,17 @@ class DataService extends ChangeNotifier {
|
||||
bool _isOnThisDayLoading = false;
|
||||
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
||||
|
||||
static const List<EventField> _fallbackEventFields = [
|
||||
EventField(name: 'operator', display: 'Operator'),
|
||||
EventField(name: 'status', display: 'Status'),
|
||||
EventField(name: 'evn', display: 'EVN'),
|
||||
EventField(name: 'owner', display: 'Owner'),
|
||||
EventField(name: 'location', display: 'Location'),
|
||||
EventField(name: 'livery', display: 'Livery'),
|
||||
EventField(name: 'domain', display: 'Domain'),
|
||||
EventField(name: 'type', display: 'Type'),
|
||||
];
|
||||
|
||||
void _notifyAsync() {
|
||||
// Always defer to the next frame to avoid setState during build.
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -260,6 +275,75 @@ class DataService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
||||
try {
|
||||
final json = await api.get('/trips/stats?trip_id=$tripId');
|
||||
if (json is List) {
|
||||
return json
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => TripLocoStat.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
if (json is Map && json['locos'] is List) {
|
||||
return (json['locos'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => TripLocoStat.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch trip loco stats: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchEventFields({bool force = false}) async {
|
||||
if (_eventFields.isNotEmpty && !force) return;
|
||||
_isEventFieldsLoading = true;
|
||||
_notifyAsync();
|
||||
try {
|
||||
final json = await api.get('/event/fields');
|
||||
List<EventField> fields = _parseEventFields(json);
|
||||
if (fields.isEmpty) {
|
||||
fields = _fallbackEventFields;
|
||||
}
|
||||
_eventFields = fields;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch event fields: $e');
|
||||
_eventFields = _fallbackEventFields;
|
||||
} finally {
|
||||
_isEventFieldsLoading = false;
|
||||
_notifyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
List<EventField> _parseEventFields(dynamic json) {
|
||||
if (json is List) {
|
||||
return json
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(EventField.fromJson)
|
||||
.toList();
|
||||
}
|
||||
if (json is Map) {
|
||||
if (json['fields'] is List) {
|
||||
return (json['fields'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(EventField.fromJson)
|
||||
.toList();
|
||||
}
|
||||
// If map of name -> definition
|
||||
return json.entries
|
||||
.where((entry) => entry.value is Map<String, dynamic>)
|
||||
.map((entry) {
|
||||
final map = Map<String, dynamic>.from(entry.value);
|
||||
map['name'] = entry.key;
|
||||
return EventField.fromJson(map);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> fetchTrips() async {
|
||||
try {
|
||||
final json = await api.get('/trips/mileage');
|
||||
@@ -314,6 +398,7 @@ class DataService extends ChangeNotifier {
|
||||
_onThisDay = [];
|
||||
_trips = [];
|
||||
_tripDetails = [];
|
||||
_eventFields = [];
|
||||
_notifyAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores the auth token in secure storage and falls back to SharedPreferences
|
||||
/// so debug builds and platforms without a working keyring still persist.
|
||||
class TokenStorageService {
|
||||
// Singleton pattern (optional but usually handy for services)
|
||||
TokenStorageService._internal();
|
||||
|
||||
static final TokenStorageService _instance = TokenStorageService._internal();
|
||||
@@ -9,26 +11,45 @@ class TokenStorageService {
|
||||
factory TokenStorageService() => _instance;
|
||||
|
||||
static const _tokenKey = 'auth_token';
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
// Use const constructor for secure storage
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
Future<SharedPreferences> get _prefs async =>
|
||||
await SharedPreferences.getInstance();
|
||||
|
||||
/// Save or update the token
|
||||
Future<void> setToken(String token) async {
|
||||
await _storage.write(key: _tokenKey, value: token);
|
||||
try {
|
||||
await _secureStorage.write(key: _tokenKey, value: token);
|
||||
} catch (_) {
|
||||
// ignore secure storage failures in debug/unsupported environments
|
||||
}
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_tokenKey, token);
|
||||
}
|
||||
|
||||
/// Retrieve the stored token (null if none)
|
||||
Future<String?> getToken() async {
|
||||
return _storage.read(key: _tokenKey);
|
||||
try {
|
||||
final secured = await _secureStorage.read(key: _tokenKey);
|
||||
if (secured != null && secured.isNotEmpty) {
|
||||
return secured;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore and fall back
|
||||
}
|
||||
final prefs = await _prefs;
|
||||
final token = prefs.getString(_tokenKey);
|
||||
return (token == null || token.isEmpty) ? null : token;
|
||||
}
|
||||
|
||||
/// Delete the token
|
||||
Future<void> clearToken() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
try {
|
||||
await _secureStorage.delete(key: _tokenKey);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
final prefs = await _prefs;
|
||||
await prefs.remove(_tokenKey);
|
||||
}
|
||||
|
||||
/// Optional: check quickly if a token exists
|
||||
Future<bool> hasToken() async {
|
||||
final token = await getToken();
|
||||
return token != null && token.isNotEmpty;
|
||||
|
||||
Reference in New Issue
Block a user