QoL changes
All checks were successful
Release / meta (push) Successful in 22s
Release / linux-build (push) Successful in 4m32s
Release / android-build (push) Successful in 7m10s
Release / release-dev (push) Successful in 9s
Release / release-master (push) Successful in 9s

This commit is contained in:
2025-12-14 09:45:32 +00:00
parent 8116cfe7b1
commit f0dfbd185b
11 changed files with 887 additions and 321 deletions

View File

@@ -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,

View File

@@ -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];
}
}

View File

@@ -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();
},
),
],

View File

@@ -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(),
),
);
}
}

View File

@@ -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,
),
),
);
},
),
),
],
),
],
),
);
},
),
);
},

View File

@@ -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();

View File

@@ -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,
);
}
}

View File

@@ -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({

View File

@@ -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();
}

View File

@@ -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;