diff --git a/.gitignore b/.gitignore index 79c113f..7676861 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +api_return_examples.txt \ No newline at end of file diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index f196ea4..e80d2d9 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -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 createState() => _DashboardState(); +} + +class _DashboardState extends State { + bool _showAllOnThisDay = false; + @override Widget build(BuildContext context) { final data = context.watch(); final auth = context.watch(); 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 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, diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index ba20625..b590f33 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -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 { runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ - SegmentedButton( - 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 { 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 { '${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 _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 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 _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]; + } } diff --git a/lib/components/pages/new_entry.dart b/lib/components/pages/new_entry.dart index f64c481..e7f63a1 100644 --- a/lib/components/pages/new_entry.dart +++ b/lib/components/pages/new_entry.dart @@ -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 { + static const _draftPrefsKey = 'new_entry_draft'; + final _formKey = GlobalKey(); DateTime _selectedDate = DateTime.now(); TimeOfDay _selectedTime = TimeOfDay.now(); @@ -31,20 +35,42 @@ class _NewEntryPageState extends State { 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(); 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 { 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 { : 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 { _mileageController.text = result.distance.toStringAsFixed(2); _useManualMileage = false; }); + _saveDraft(); } } @@ -183,6 +214,7 @@ class _NewEntryPageState extends State { ); } }); + _saveDraft(); }, ), ), @@ -197,6 +229,7 @@ class _NewEntryPageState extends State { lastDate: DateTime.now().add(const Duration(days: 365)), ); if (picked != null) setState(() => _selectedDate = picked); + _saveDraft(); } Future _pickTime() async { @@ -204,7 +237,10 @@ class _NewEntryPageState extends State { 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 { 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 { } } + Future _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 _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 _clearDraft() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_draftPrefsKey); + } + + Future _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) { + _routeResult = + RouteResult.fromJson(Map.from(data['routeResult'])); + _mileageController.text = _routeResult!.distance.toStringAsFixed(2); + } + if (data['tractionItems'] is List) { + _restoreTractionItems(List>.from( + data['tractionItems'].cast(), + )); + } + }); + _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> _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> items) { + final restored = <_TractionItem>[]; + for (final item in items) { + final locoData = item['loco'] as Map?; + 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 { setState(() { _useManualMileage = val; }); + _saveDraft(); }, ), if (_useManualMileage) @@ -506,6 +703,7 @@ class _NewEntryPageState extends State { final item = _tractionItems.removeAt(oldIndex); _tractionItems.insert(newIndex, item); }); + _saveDraft(); }, itemCount: _tractionItems.length, itemBuilder: (context, index) { @@ -549,6 +747,7 @@ class _NewEntryPageState extends State { setState(() { _tractionItems[index] = item.copyWith(powering: v); }); + _saveDraft(); }, ), IconButton( @@ -557,6 +756,7 @@ class _NewEntryPageState extends State { setState(() { _tractionItems.removeAt(index); }); + _saveDraft(); }, ), ], diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 8d6daee..7b0ee81 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -23,22 +23,15 @@ class _TractionPageState extends State { 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 _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 _dynamicControllers = {}; + final Map _enumSelections = {}; @override void initState() { @@ -53,7 +46,9 @@ class _TractionPageState extends State { _initialised = true; _selectedKeys = {...widget.selectedKeys}; WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().fetchClassList(); + final data = context.read(); + data.fetchClassList(); + data.fetchEventFields(); _refreshTraction(); }); } @@ -66,47 +61,41 @@ class _TractionPageState extends State { _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 _refreshTraction({bool append = false}) async { final data = context.read(); - 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 = {}; + 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 { } 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 { } } + List _activeEventFields(List fields) { + return fields + .where( + (field) => + !['class', 'number', 'name', 'build date', 'build_date'] + .contains(field.name.toLowerCase()), + ) + .toList(); + } + + void _ensureControllersForFields(List 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(); 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 { ); }, 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 { : 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 { 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 { 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 { 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 { ), 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 { ); } + (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 _showLocoInfo(LocoSummary loco) async { await showModalBottomSheet( context: context, @@ -750,4 +711,70 @@ class _TractionPageState extends State { 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( + 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(), + ), + ); + } } diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index 111ad5e..74eb4b1 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -269,51 +269,85 @@ class _TripsPageState extends State { context: context, isScrollControlled: true, builder: (_) { + final data = context.read(); return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: FutureBuilder>( + 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, + ), + ), + ); + }, + ), + ), + ], ), - ], - ), + ); + }, ), ); }, diff --git a/lib/main.dart b/lib/main.dart index 0ef41c1..748b223 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -188,18 +188,19 @@ class _MyHomePageState extends State { if (!_fetched) { _fetched = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - Future(() async { - final data = context.read(); - final auth = context.read(); - 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(); + final auth = context.read(); + 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(); diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 1d4cfba..b0ee833 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -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 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? enumValues; + + const EventField({ + required this.name, + required this.display, + this.type, + this.enumValues, + }); + + factory EventField.fromJson(Map json) { + final enumList = json['enum']; + List? 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, + ); + } +} diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index de3f485..ecedfa2 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -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 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 _persistToken(String token) async { - await _storage.write(key: _tokenKey, value: token); + await _tokenStorage.setToken(token); } Future _clearToken() async { - await _storage.delete(key: _tokenKey); + await _tokenStorage.clearToken(); } Future register({ diff --git a/lib/services/dataService.dart b/lib/services/dataService.dart index a38da86..cc5fc8e 100644 --- a/lib/services/dataService.dart +++ b/lib/services/dataService.dart @@ -61,6 +61,10 @@ class DataService extends ChangeNotifier { List get locoClasses => _locoClasses; List _tripList = []; List get tripList => _tripList; + List _eventFields = []; + List get eventFields => _eventFields; + bool _isEventFieldsLoading = false; + bool get isEventFieldsLoading => _isEventFieldsLoading; // Station Data List? _cachedStations; @@ -73,6 +77,17 @@ class DataService extends ChangeNotifier { bool _isOnThisDayLoading = false; bool get isOnThisDayLoading => _isOnThisDayLoading; + static const List _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> fetchTripLocoStats(int tripId) async { + try { + final json = await api.get('/trips/stats?trip_id=$tripId'); + if (json is List) { + return json + .whereType>() + .map((e) => TripLocoStat.fromJson(e)) + .toList(); + } + if (json is Map && json['locos'] is List) { + return (json['locos'] as List) + .whereType>() + .map((e) => TripLocoStat.fromJson(e)) + .toList(); + } + return []; + } catch (e) { + debugPrint('Failed to fetch trip loco stats: $e'); + return []; + } + } + + Future fetchEventFields({bool force = false}) async { + if (_eventFields.isNotEmpty && !force) return; + _isEventFieldsLoading = true; + _notifyAsync(); + try { + final json = await api.get('/event/fields'); + List 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 _parseEventFields(dynamic json) { + if (json is List) { + return json + .whereType>() + .map(EventField.fromJson) + .toList(); + } + if (json is Map) { + if (json['fields'] is List) { + return (json['fields'] as List) + .whereType>() + .map(EventField.fromJson) + .toList(); + } + // If map of name -> definition + return json.entries + .where((entry) => entry.value is Map) + .map((entry) { + final map = Map.from(entry.value); + map['name'] = entry.key; + return EventField.fromJson(map); + }) + .toList(); + } + return []; + } + Future fetchTrips() async { try { final json = await api.get('/trips/mileage'); @@ -314,6 +398,7 @@ class DataService extends ChangeNotifier { _onThisDay = []; _trips = []; _tripDetails = []; + _eventFields = []; _notifyAsync(); } diff --git a/lib/services/tokenStorageService.dart b/lib/services/tokenStorageService.dart index b82ed79..ca4be51 100644 --- a/lib/services/tokenStorageService.dart +++ b/lib/services/tokenStorageService.dart @@ -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 get _prefs async => + await SharedPreferences.getInstance(); - /// Save or update the token Future 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 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 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 hasToken() async { final token = await getToken(); return token != null && token.isNotEmpty;