Compare commits

...

18 Commits

Author SHA1 Message Date
45bd872b23 add support for network calculation from the calculator
All checks were successful
Release / meta (push) Successful in 1m39s
Release / linux-build (push) Successful in 1m55s
Release / web-build (push) Successful in 3m12s
Release / android-build (push) Successful in 6m48s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 28s
2026-01-27 00:41:27 +00:00
94adf06726 add filter by network for legs, add full export for traction
All checks were successful
Release / meta (push) Successful in 1m45s
Release / linux-build (push) Successful in 1m48s
Release / web-build (push) Successful in 1m56s
Release / android-build (push) Successful in 7m33s
Release / release-dev (push) Successful in 30s
Release / release-master (push) Successful in 5s
2026-01-26 15:57:34 +00:00
8340501f37 make carousel more stable
All checks were successful
Release / meta (push) Successful in 4s
Release / linux-build (push) Successful in 1m14s
Release / web-build (push) Successful in 1m30s
Release / android-build (push) Successful in 5m52s
Release / release-dev (push) Successful in 7s
Release / release-master (push) Successful in 4s
2026-01-23 18:24:34 +00:00
a527ecdb17 add logo to login page 2026-01-23 18:04:40 +00:00
f3fcf07b05 add refresh token support 2026-01-23 17:55:55 +00:00
9896b6f1f8 add traction import export
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m1s
Release / web-build (push) Successful in 1m22s
Release / android-build (push) Successful in 6m49s
Release / release-master (push) Successful in 5s
Release / release-dev (push) Successful in 8s
2026-01-23 13:21:08 +00:00
56cc7c0902 fix mileograph tag issue
All checks were successful
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m36s
Release / web-build (push) Successful in 1m18s
Release / android-build (push) Successful in 6m41s
Release / release-master (push) Successful in 15s
Release / release-dev (push) Successful in 20s
2026-01-23 00:25:47 +00:00
917d020ef5 fix release pipelines
Some checks failed
Release / meta (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-23 00:22:56 +00:00
d8312a3f1b Added class clearance fixes and improvements
Some checks failed
Release / meta (push) Successful in 18s
Release / android-build (push) Successful in 11m48s
Release / linux-build (push) Successful in 1m24s
Release / web-build (push) Successful in 6m15s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-22 23:17:15 +00:00
559f79b805 add transfer all button for admins
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 1m4s
Release / web-build (push) Successful in 2m47s
Release / android-build (push) Successful in 11m45s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 30s
2026-01-12 17:11:37 +00:00
3b7ec31e5d add refresh button to pending page
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Successful in 2m23s
Release / android-build (push) Successful in 7m48s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-12 16:37:01 +00:00
e9b328e7e6 improve pending visibility, allow multiple users to have pending changes against locos. 2026-01-12 16:16:36 +00:00
45042b5001 new pending visibility
Some checks failed
Release / meta (push) Successful in 8s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
2026-01-12 16:03:47 +00:00
5c0043146f minor page tweaks 2026-01-12 15:30:29 +00:00
91f5391684 fix edit widget issues 2026-01-12 15:00:09 +00:00
f06a1c75b6 Fix transfer functoin, add display for numer of pending locos
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m10s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 9m2s
Release / release-master (push) Successful in 17s
Release / release-dev (push) Successful in 19s
2026-01-06 14:44:12 +00:00
5b94ab263b move transfer button, hide delete button better
All checks were successful
Release / meta (push) Successful in 28s
Release / linux-build (push) Successful in 54s
Release / web-build (push) Successful in 4m48s
Release / android-build (push) Successful in 15m12s
Release / release-master (push) Successful in 31s
Release / release-dev (push) Successful in 34s
2026-01-06 12:04:34 +00:00
06bed86a49 Add accent colour picker, fix empty user card when accepting friend request, add button to transfer allocations
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 56s
Release / web-build (push) Successful in 2m15s
Release / android-build (push) Successful in 6m47s
Release / release-master (push) Successful in 19s
Release / release-dev (push) Successful in 21s
2026-01-06 00:21:19 +00:00
55 changed files with 5241 additions and 811 deletions

View File

@@ -18,7 +18,7 @@ env:
jobs: jobs:
meta: meta:
runs-on: runs-on:
- mileograph - tgj-arc
outputs: outputs:
base_version: ${{ steps.meta.outputs.base }} base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }} release_tag: ${{ steps.meta.outputs.release_tag }}
@@ -317,7 +317,7 @@ jobs:
release-dev: release-dev:
runs-on: runs-on:
- mileograph - tgj-arc
needs: needs:
- meta - meta
- android-build - android-build
@@ -423,7 +423,7 @@ jobs:
release-master: release-master:
runs-on: runs-on:
- mileograph - tgj-arc
needs: needs:
- meta - meta
- android-build - android-build

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1">
<path
style="fill:#00b7fd;stroke-width:4.40315"
d="M 42.563242,0 H 92.5 A 27.5,27.5 45 0 1 120,27.5 27.5,27.5 135 0 1 92.5,55 h -50 A 2.5,2.5 45 0 1 40,52.5 v -10 A 2.5,2.5 135 0 1 42.5,40 h 50 A 12.5,12.5 135 0 0 105,27.5 12.5,12.5 45 0 0 92.5,15 l -50,0 A 27.5,27.5 135 0 0 15,42.5 v 50 A 2.5,2.5 135 0 1 12.5,95 H 2.5 A 2.5,2.5 45 0 1 0,92.5 V 42.563242 A 42.563242,42.563242 135 0 1 42.563242,0 Z"
id="path1"
transform="translate(0,12.5)" />
<path
style="fill:#999999;stroke-width:4.40315"
d="m 42.5,60 h 60 A 17.5,17.5 45 0 1 120,77.5 17.5,17.5 135 0 1 102.5,95 h -60 A 22.5,22.5 45 0 1 20,72.5 v -30 A 22.5,22.5 135 0 1 42.5,20 h 50 a 7.5,7.5 45 0 1 7.5,7.5 7.5,7.5 135 0 1 -7.5,7.5 h -50 A 7.5,7.5 135 0 0 35,42.5 v 30 a 7.5,7.5 45 0 0 7.5,7.5 h 60 A 2.5,2.5 135 0 0 105,77.5 2.5,2.5 45 0 0 102.5,75 h -60 A 2.5,2.5 45 0 1 40,72.5 v -10 A 2.5,2.5 135 0 1 42.5,60 Z"
id="path2"
transform="translate(0,12.5)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -17,9 +19,15 @@ class App extends StatelessWidget {
ChangeNotifierProvider<EndpointService>( ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(), create: (_) => EndpointService(),
), ),
ChangeNotifierProvider<AccentColorService>(
create: (_) => AccentColorService(),
),
ChangeNotifierProvider<DistanceUnitService>( ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(), create: (_) => DistanceUnitService(),
), ),
ChangeNotifierProvider<ThemeModeService>(
create: (_) => ThemeModeService(),
),
ProxyProvider<EndpointService, ApiService>( ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) { update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl); final service = api ?? ApiService(baseUrl: endpoint.baseUrl);

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import './route_summary_widget.dart'; import './route_summary_widget.dart';
@@ -133,6 +134,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
bool _loadingStations = false; bool _loadingStations = false;
RouteResult? _routeResult; RouteResult? _routeResult;
List<String>? _calculatedStations;
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
String? _errorMessage; String? _errorMessage;
@@ -178,6 +180,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (cleaned.length < 2) { if (cleaned.length < 2) {
setState(() { setState(() {
_routeResult = null; _routeResult = null;
_calculatedStations = null;
_errorMessage = 'Add at least two stations before calculating.'; _errorMessage = 'Add at least two stations before calculating.';
}); });
return; return;
@@ -185,6 +188,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
_routeResult = null; _routeResult = null;
_calculatedStations = null;
}); });
final api = context.read<ApiService>(); // context is valid here final api = context.read<ApiService>(); // context is valid here
try { try {
@@ -195,6 +199,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (res is Map && res['error'] == false) { if (res is Map && res['error'] == false) {
setState(() { setState(() {
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res)); _routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
_calculatedStations = List.from(cleaned);
}); });
final distance = (_routeResult?.distance ?? 0); final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance); widget.onDistanceComputed?.call(distance);
@@ -205,17 +210,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
).msg; ).msg;
}); });
} else { } else {
setState(() => _errorMessage = 'Failed to calculate route.'); setState(() {
_errorMessage = 'Failed to calculate route.';
_calculatedStations = null;
});
} }
} catch (e) { } catch (e) {
setState(() => _errorMessage = 'Failed to calculate route: $e'); setState(() {
_errorMessage = 'Failed to calculate route: $e';
_calculatedStations = null;
});
} }
} }
void _markRouteDirty() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = null;
}
void _addStation() { void _addStation() {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.add(''); data.stations.add('');
_markRouteDirty();
}); });
} }
@@ -223,6 +241,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.removeAt(index); data.stations.removeAt(index);
_markRouteDirty();
}); });
} }
@@ -230,6 +249,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations[index] = value; data.stations[index] = value;
_markRouteDirty();
}); });
} }
@@ -237,14 +257,91 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations = ['']; data.stations = [''];
_routeResult = null; _markRouteDirty();
_errorMessage = null;
}); });
} }
bool _isResultCurrent(List<String> stations) {
if (_routeResult == null || _calculatedStations == null) return false;
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length != _calculatedStations!.length) return false;
for (var i = 0; i < cleaned.length; i++) {
if (cleaned[i] != _calculatedStations![i]) return false;
}
return true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final isCompact = MediaQuery.of(context).size.width < 600;
final showApply =
widget.onApplyRoute != null && _isResultCurrent(data.stations);
final primaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 14 : 20,
vertical: isCompact ? 10 : 14,
);
final secondaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 10 : 16,
vertical: isCompact ? 8 : 12,
);
final primaryStyle = FilledButton.styleFrom(
padding: primaryPadding,
minimumSize: Size(0, isCompact ? 38 : 46),
);
final secondaryStyle = OutlinedButton.styleFrom(
padding: secondaryPadding,
minimumSize: Size(0, isCompact ? 34 : 42),
);
Widget buildSecondaryButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
if (isCompact) {
return Tooltip(
message: label,
child: OutlinedButton(
onPressed: onPressed,
style: secondaryStyle,
child: Icon(icon, size: 20),
),
);
}
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 20),
label: Text(label),
style: secondaryStyle,
);
}
Widget buildPrimaryAction({required bool fullWidth}) {
final button = showApply
? FilledButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
style: primaryStyle,
)
: FilledButton.icon(
onPressed: () async {
await _calculateRoute(data.stations);
},
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
style: primaryStyle,
);
final key =
ValueKey<String>(showApply ? 'apply-primary-action' : 'calc-primary-action');
if (!fullWidth) return KeyedSubtree(key: key, child: button);
return KeyedSubtree(
key: key,
child: SizedBox(width: double.infinity, child: button),
);
}
return Column( return Column(
children: [ children: [
Align( Align(
@@ -262,7 +359,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
runSpacing: 12, runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_MultiSelectFilter( MultiSelectFilter(
label: 'Countries', label: 'Countries',
options: _countries, options: _countries,
selected: _selectedCountries, selected: _selectedCountries,
@@ -271,7 +368,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
_loadStations(); _loadStations();
}, },
), ),
_MultiSelectFilter( MultiSelectFilter(
label: 'Networks', label: 'Networks',
options: _networks, options: _networks,
selected: _selectedNetworks, selected: _selectedNetworks,
@@ -279,6 +376,16 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() => _selectedNetworks = vals); setState(() => _selectedNetworks = vals);
_loadStations(); _loadStations();
}, },
onRefresh: () async {
final data = context.read<DataService>();
await data.fetchStationFilters();
if (!mounted) return;
setState(() {
_networks = data.stationNetworks;
_countries = data.stationCountryNetworks.keys.toList();
});
await _loadStations();
},
), ),
if (_loadingStations) if (_loadingStations)
const Padding( const Padding(
@@ -301,6 +408,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
final moved = data.stations.removeAt(oldIndex); final moved = data.stations.removeAt(oldIndex);
data.stations.insert(newIndex, moved); data.stations.insert(newIndex, moved);
_markRouteDirty();
}); });
}, },
children: List.generate(data.stations.length, (index) { children: List.generate(data.stations.length, (index) {
@@ -364,56 +472,94 @@ class _RouteCalculatorState extends State<RouteCalculator> {
context.push('/calculator/details', extra: result); context.push('/calculator/details', extra: result);
}, },
), ),
if (widget.onApplyRoute != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
),
),
] ]
else else
SizedBox.shrink(), SizedBox.shrink(),
const SizedBox(height: 10), const SizedBox(height: 10),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: isCompact
alignment: WrapAlignment.center, ? Column(
spacing: 12, children: [
runSpacing: 8, Row(
children: [ mainAxisAlignment: MainAxisAlignment.center,
...(() { children: [
final reverseButton = ElevatedButton.icon( buildSecondaryButton(
icon: const Icon(Icons.swap_horiz), icon: Icons.swap_horiz,
label: const Text('Reverse route'), label: 'Reverse route',
onPressed: () async { onPressed: () async {
setState(() { setState(() {
data.stations = data.stations.reversed.toList(); data.stations = data.stations.reversed.toList();
}); });
await _calculateRoute(data.stations); await _calculateRoute(data.stations);
}, },
); ),
final addButton = ElevatedButton.icon( const SizedBox(width: 12),
icon: const Icon(Icons.add), buildSecondaryButton(
label: const Text('Add Station'), icon: Icons.add,
onPressed: _addStation, label: 'Add station',
); onPressed: _addStation,
final calculateButton = ElevatedButton.icon( ),
icon: const Icon(Icons.route), ],
label: const Text('Calculate Route'), ),
onPressed: () async { const SizedBox(height: 10),
await _calculateRoute(data.stations); SizedBox(
}, width: double.infinity,
); child: AnimatedSwitcher(
final isMobile = MediaQuery.of(context).size.width < 600; duration: const Duration(milliseconds: 220),
return isMobile transitionBuilder: (child, animation) {
? [addButton, reverseButton, calculateButton] final curved = CurvedAnimation(
: [reverseButton, addButton, calculateButton]; parent: animation,
})(), curve: Curves.easeOutBack,
], );
), return ScaleTransition(
scale:
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
);
},
child: buildPrimaryAction(fullWidth: true),
),
),
],
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
);
return ScaleTransition(
scale:
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
);
},
child: buildPrimaryAction(fullWidth: false),
),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -438,159 +584,3 @@ Widget debugPanel(List<String> stations) {
), ),
); );
} }
class _MultiSelectFilter extends StatefulWidget {
const _MultiSelectFilter({
required this.label,
required this.options,
required this.selected,
required this.onChanged,
});
final String label;
final List<String> options;
final List<String> selected;
final ValueChanged<List<String>> onChanged;
@override
State<_MultiSelectFilter> createState() => _MultiSelectFilterState();
}
class _MultiSelectFilterState extends State<_MultiSelectFilter> {
late List<String> _tempSelected;
String _query = '';
@override
void initState() {
super.initState();
_tempSelected = List.from(widget.selected);
}
@override
void didUpdateWidget(covariant _MultiSelectFilter oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
_tempSelected = List.from(widget.selected);
}
}
void _openPicker() async {
_tempSelected = List.from(widget.selected);
_query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final filtered = widget.options
.where((opt) =>
_query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase()))
.toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select ${widget.label.toLowerCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton(
onPressed: () {
setModalState(() {
_tempSelected.clear();
});
Navigator.of(ctx).pop();
widget.onChanged(const []);
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
onChanged: (val) {
setModalState(() {
_query = val;
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final option = filtered[index];
final selected = _tempSelected.contains(option);
return CheckboxListTile(
value: selected,
title: Text(option),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!_tempSelected.contains(option)) {
_tempSelected.add(option);
}
} else {
_tempSelected.removeWhere((e) => e == option);
}
});
widget.onChanged(List.from(_tempSelected.toSet()));
},
);
},
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () {
widget.onChanged(List.from(_tempSelected.toSet()));
Navigator.of(ctx).pop();
},
icon: const Icon(Icons.check),
label: const Text('Apply'),
),
),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final hasSelection = widget.selected.isNotEmpty;
final display =
hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}';
return OutlinedButton.icon(
onPressed: _openPicker,
icon: const Icon(Icons.filter_alt),
label: SizedBox(
width: 180,
child: Text(
'${widget.label}: $display',
overflow: TextOverflow.ellipsis,
),
),
);
}
}

View File

@@ -40,6 +40,7 @@ class RouteDetailsView extends StatelessWidget {
final List<double> costs; final List<double> costs;
final VoidCallback onBack; final VoidCallback onBack;
final Set<String> routingPoints; final Set<String> routingPoints;
final VoidCallback? onNetworksPressed;
const RouteDetailsView({ const RouteDetailsView({
super.key, super.key,
@@ -47,6 +48,7 @@ class RouteDetailsView extends StatelessWidget {
required this.costs, required this.costs,
required this.onBack, required this.onBack,
this.routingPoints = const {}, this.routingPoints = const {},
this.onNetworksPressed,
}); });
@override @override
@@ -56,13 +58,21 @@ class RouteDetailsView extends StatelessWidget {
final mutedColor = Theme.of(context).colorScheme.outlineVariant; final mutedColor = Theme.of(context).colorScheme.outlineVariant;
return Column( return Column(
children: [ children: [
Align( Row(
alignment: Alignment.centerLeft, children: [
child: TextButton.icon( TextButton.icon(
onPressed: onBack, onPressed: onBack,
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
label: const Text('Back'), label: const Text('Back'),
), ),
const Spacer(),
if (onNetworksPressed != null)
TextButton.icon(
onPressed: onNetworksPressed,
icon: const Icon(Icons.account_tree),
label: const Text('Networks'),
),
],
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
@@ -136,11 +137,10 @@ class _LeaderboardPanelState extends State<LeaderboardPanel> {
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8, spacing: 8,
children: [ children: [
Text( AnimatedCountText(
distanceUnits.format( value: leaderboard[index].mileage,
leaderboard[index].mileage, formatter: (val) =>
decimals: 1, distanceUnits.format(val, decimals: 1),
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -78,11 +79,10 @@ class TopTractionPanel extends StatelessWidget {
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
), ),
trailing: Text( trailing: AnimatedCountText(
distanceUnits.format( value: (locos[index].mileage ?? 0).toDouble(),
locos[index].mileage ?? 0, formatter: (val) =>
decimals: 1, distanceUnits.format(val, decimals: 1),
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -32,6 +32,8 @@ class _LegCardState extends State<LegCard> {
final sharedTo = leg.sharedTo; final sharedTo = leg.sharedTo;
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final networkMileage = _sortedNetworkMileage(leg);
final countryMileage = _sortedCountryMileage(leg);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -160,10 +162,11 @@ class _LegCardState extends State<LegCard> {
), ),
); );
} }
if (leg.network.isNotEmpty) { final networkSummary = _networkSummary(leg);
if (networkSummary != null) {
children.add( children.add(
Text( Text(
leg.network, networkSummary,
style: textTheme.labelSmall, style: textTheme.labelSmall,
), ),
); );
@@ -285,6 +288,28 @@ class _LegCardState extends State<LegCard> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
if (networkMileage.isNotEmpty || countryMileage.isNotEmpty) ...[
Text('Network mileage', style: textTheme.titleSmall),
const SizedBox(height: 6),
...networkMileage.map(
(entry) => Text(
'${entry.network}: ${distanceUnits.format(entry.miles, decimals: 1)}',
style: textTheme.bodyMedium,
),
),
if (countryMileage.isNotEmpty) ...[
const SizedBox(height: 8),
Text('Country mileage', style: textTheme.titleSmall),
const SizedBox(height: 6),
...countryMileage.map(
(entry) => Text(
'${entry.country}: ${distanceUnits.format(entry.miles, decimals: 1)}',
style: textTheme.bodyMedium,
),
),
],
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[ if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall), Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -483,6 +508,33 @@ class _LegCardState extends State<LegCard> {
List<String> _parseRouteSegments(List<String> route) { List<String> _parseRouteSegments(List<String> route) {
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList(); return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
} }
List<NetworkMileage> _sortedNetworkMileage(Leg leg) {
final items = leg.networkMileage
.where((entry) => entry.network.trim().isNotEmpty)
.toList();
items.sort((a, b) => b.miles.compareTo(a.miles));
return items;
}
List<CountryMileage> _sortedCountryMileage(Leg leg) {
final items = leg.countryMileage
.where((entry) => entry.country.trim().isNotEmpty)
.toList();
items.sort((a, b) => b.miles.compareTo(a.miles));
return items;
}
String? _networkSummary(Leg leg) {
final networks = _sortedNetworkMileage(leg);
if (networks.isNotEmpty) {
return networks.map((entry) => entry.network).join(', ');
}
if (leg.network.trim().isNotEmpty) {
return leg.network;
}
return null;
}
} }
class _SharedIcons extends StatelessWidget { class _SharedIcons extends StatelessWidget {

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/settings.dart';
@@ -41,71 +43,101 @@ class _LoginScreenState extends State<LoginScreen> {
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
body: Container( body: Container(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: Center( child: SafeArea(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text.rich( Expanded(
TextSpan( child: Center(
children: [ child: Column(
TextSpan( mainAxisAlignment: MainAxisAlignment.center,
text: "Mile", children: [
style: TextStyle( LayoutBuilder(
color: Theme.of(context).textTheme.bodyLarge?.color, builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "Mile",
style: TextStyle(
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
const TextSpan(
text: "O",
style: TextStyle(color: Colors.red),
),
TextSpan(
text: "graph",
style: TextStyle(
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
],
style: const TextStyle(
decoration: TextDecoration.none,
color: Colors.white,
fontFamily: "Tomatoes",
fontSize: 50,
),
),
softWrap: false,
overflow: TextOverflow.visible,
),
),
);
},
), ),
), const SizedBox(height: 40),
const TextSpan( if (_checkingSession) ...[
text: "O", const SizedBox(height: 12),
style: TextStyle(color: Colors.red), Row(
), mainAxisAlignment: MainAxisAlignment.center,
TextSpan( children: [
text: "graph", const SizedBox(
style: TextStyle( height: 24,
color: Theme.of(context).textTheme.bodyLarge?.color, width: 24,
), child: CircularProgressIndicator(strokeWidth: 2),
), ),
], const SizedBox(width: 8),
style: const TextStyle( Text(
decoration: TextDecoration.none, 'Trying to log in',
color: Colors.white, style: Theme.of(context).textTheme.bodyMedium,
fontFamily: "Tomatoes", ),
fontSize: 50, ],
),
] else ...[
const SizedBox(height: 10),
const LoginPanel(),
const SizedBox(height: 16),
IconButton(
icon: const Icon(Icons.settings, color: Colors.grey),
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => const SettingsPage(),
),
);
},
),
],
],
), ),
), ),
), ),
const SizedBox(height: 50), const Padding(
const LoginPanel(), padding: EdgeInsets.only(bottom: 12),
const SizedBox(height: 16), child: _LoginLogo(),
IconButton(
icon: const Icon(Icons.settings, color: Colors.grey),
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => const SettingsPage(),
),
);
},
), ),
if (_checkingSession) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Trying to log in',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
],
], ],
), ),
), ),
@@ -114,6 +146,57 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} }
class _LoginLogo extends StatefulWidget {
const _LoginLogo();
@override
State<_LoginLogo> createState() => _LoginLogoState();
}
class _LoginLogoState extends State<_LoginLogo> {
late final Future<String> _svgFuture;
@override
void initState() {
super.initState();
_svgFuture = rootBundle.loadString('assets/logos/pg_logo_v2.svg');
}
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final grey = const Color(0xFF999999);
return SizedBox(
height: 42,
child: Opacity(
opacity: 0.75,
child: FutureBuilder<String>(
future: _svgFuture,
builder: (context, snapshot) {
final svg = snapshot.data;
if (svg == null) {
return const SizedBox.shrink();
}
final tinted = svg
.replaceAll('#00b7fd', _colorToHex(accent))
.replaceAll('#999999', _colorToHex(grey));
return SvgPicture.string(
tinted,
fit: BoxFit.contain,
semanticsLabel: 'Mileograph logo',
);
},
),
),
);
}
String _colorToHex(Color color) {
final hex = color.toARGB32().toRadixString(16).padLeft(8, '0');
return '#${hex.substring(2)}';
}
}
class LoginPanel extends StatefulWidget { class LoginPanel extends StatefulWidget {
const LoginPanel({super.key}); const LoginPanel({super.key});

View File

@@ -449,6 +449,23 @@ class _BadgesPageState extends State<BadgesPage> {
ClassClearanceProgress progress, ClassClearanceProgress progress,
) { ) {
final pct = progress.percentComplete.clamp(0, 100); final pct = progress.percentComplete.clamp(0, 100);
final activePct = progress.activePercent.clamp(0, 100);
final showActive =
progress.activeTotal > 0 || progress.activeCompleted > 0;
final showActiveCrest =
progress.activeTotal > 0 && progress.activeCompleted == progress.activeTotal;
final scheme = Theme.of(context).colorScheme;
final total = progress.total;
final activeTotal = progress.activeTotal.clamp(0, total);
final had = progress.completed.clamp(0, total);
final activeHad = progress.activeCompleted.clamp(0, activeTotal).clamp(0, had);
final activeRemaining =
(activeTotal - activeHad).clamp(0, total - had);
final remaining =
(total - activeTotal - (had - activeHad)).clamp(0, total);
final hadColor = scheme.primary;
final activeColor = scheme.primary.withValues(alpha: 0.4);
final remainingColor = scheme.primary.withValues(alpha: 0.18);
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding( child: Padding(
@@ -471,9 +488,55 @@ class _BadgesPageState extends State<BadgesPage> {
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
LinearProgressIndicator( if (showActive)
value: progress.total == 0 ? 0 : pct / 100, Padding(
minHeight: 6, padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
children: [
Text(
'Active: ${progress.activeCompleted}/${progress.activeTotal} (${activePct.toStringAsFixed(0)}%)',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: Theme.of(context).hintColor),
),
if (showActiveCrest) ...[
const SizedBox(width: 6),
Icon(
Icons.verified,
size: 14,
color: scheme.primary,
),
],
],
),
),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
height: 6,
child: total <= 0
? Container(color: remainingColor)
: Row(
children: [
if (had > 0)
Expanded(
flex: had,
child: Container(color: hadColor),
),
if (showActive && activeRemaining > 0)
Expanded(
flex: activeRemaining,
child: Container(color: activeColor),
),
if (remaining > 0)
Expanded(
flex: remaining,
child: Container(color: remainingColor),
),
],
),
),
), ),
if (progress.total > 0) if (progress.total > 0)
Padding( Padding(

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/calculator/route_summary_widget.dart'; import 'package:mileograph_flutter/components/calculator/route_summary_widget.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class CalculatorDetailsPage extends StatelessWidget { class CalculatorDetailsPage extends StatelessWidget {
const CalculatorDetailsPage({ const CalculatorDetailsPage({
@@ -34,13 +36,85 @@ class CalculatorDetailsPage extends StatelessWidget {
); );
} }
return Padding( final networks = List<NetworkMileage>.from(parsed.networkMileage)
padding: const EdgeInsets.all(16.0), ..sort((a, b) => b.miles.compareTo(a.miles));
child: RouteDetailsView( final countries = List<CountryMileage>.from(parsed.countryMileage)
route: parsed.calculatedRoute, ..sort((a, b) => b.miles.compareTo(a.miles));
costs: parsed.costs,
routingPoints: parsed.inputRoute.toSet(), return Scaffold(
onBack: () => context.pop(), endDrawer: _NetworksDrawer(
networks: networks,
countries: countries,
),
body: Builder(
builder: (scaffoldContext) => Padding(
padding: const EdgeInsets.all(16.0),
child: RouteDetailsView(
route: parsed.calculatedRoute,
costs: parsed.costs,
routingPoints: parsed.inputRoute.toSet(),
onBack: () => context.pop(),
onNetworksPressed: () =>
Scaffold.of(scaffoldContext).openEndDrawer(),
),
),
),
);
}
}
class _NetworksDrawer extends StatelessWidget {
const _NetworksDrawer({
required this.networks,
required this.countries,
});
final List<NetworkMileage> networks;
final List<CountryMileage> countries;
@override
Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
return Drawer(
child: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Networks',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (networks.isEmpty)
const Text('No network mileage data.')
else
...networks.map(
(entry) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(entry.network),
trailing:
Text(distanceUnits.format(entry.miles, decimals: 2)),
),
),
const SizedBox(height: 16),
Text(
'Countries',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (countries.isEmpty)
const Text('No country mileage data.')
else
...countries.map(
(entry) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(entry.country),
trailing:
Text(distanceUnits.format(entry.miles, decimals: 2)),
),
),
],
),
), ),
); );
} }

View File

@@ -1,9 +1,13 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart'; import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
@@ -19,6 +23,18 @@ class Dashboard extends StatefulWidget {
class _DashboardState extends State<Dashboard> { class _DashboardState extends State<Dashboard> {
bool _showAllOnThisDay = false; bool _showAllOnThisDay = false;
bool _isCurrent = false;
Timer? _carouselTimer;
int _carouselIndex = 0;
int _carouselItemCount = 0;
final Random _carouselRandom = Random();
List<ClassClearanceProgress> _carouselItems = const [];
String _carouselSignature = '';
@override
void initState() {
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,16 +47,11 @@ class _DashboardState extends State<Dashboard> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await data.fetchHomepageStats(); await _refreshDashboardData(force: true);
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]);
}, },
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_handleRouteFocus();
const spacing = 16.0; const spacing = 16.0;
final maxWidth = constraints.maxWidth; final maxWidth = constraints.maxWidth;
return Stack( return Stack(
@@ -97,9 +108,6 @@ class _DashboardState extends State<Dashboard> {
final totalMileage = stats?.totalMileage ?? 0; final totalMileage = stats?.totalMileage ?? 0;
final currentYearMileage = data.getMileageForCurrentYear(); final currentYearMileage = data.getMileageForCurrentYear();
final legCount = stats?.legCount ?? data.trips.length; final legCount = stats?.legCount ?? data.trips.length;
final progress = totalMileage == 0
? 0.0
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -125,62 +133,248 @@ class _DashboardState extends State<Dashboard> {
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: [ children: [
_metricTile( _animatedMetricTile(
context, context,
label: 'Total mileage', label: 'Total mileage',
value: distanceUnits.format(totalMileage, decimals: 1), value: totalMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.route, icon: Icons.route,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
_metricTile( _animatedMetricTile(
context, context,
label: 'This year', label: 'This year',
value: distanceUnits.format(currentYearMileage, decimals: 1), value: currentYearMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.calendar_today, icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
_metricTile( _animatedMetricTile(
context, context,
label: 'Entries logged', label: 'Entries logged',
value: legCount.toString(), value: legCount.toDouble(),
formatter: (val) => val.round().toString(),
icon: Icons.format_list_bulleted, icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ClipRRect( _buildClassClearanceCarousel(context, data, colorScheme),
borderRadius: BorderRadius.circular(12),
child: LinearProgressIndicator(
value: progress.isNaN ? 0 : progress,
minHeight: 10,
backgroundColor: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 6),
Text(
totalMileage == 0
? 'Log a new entry to start your timeline.'
: 'Year-to-date is ${(progress * 100).toStringAsFixed(0)}% of all mileage.',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
], ],
), ),
), ),
); );
} }
Widget _metricTile( Widget _buildClassClearanceCarousel(
BuildContext context,
DataService data,
ColorScheme colorScheme,
) {
final items = data.classClearanceProgress;
final loading = data.isClassClearanceProgressLoading;
_refreshCarouselItems(items);
_startCarouselIfNeeded(_carouselItems.length);
if (loading && _carouselItems.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Center(
child: SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (_carouselItems.isEmpty) {
return Text(
'No class clearance progress yet.',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Class clearance (in progress)',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
const SizedBox(height: 6),
SizedBox(
height: 58,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 450),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final tween = Tween<Offset>(
begin: const Offset(0, 0.4),
end: Offset.zero,
);
return ClipRect(
child: SlideTransition(
position: animation.drive(tween),
child: FadeTransition(opacity: animation, child: child),
),
);
},
child: _buildClassClearanceSlide(
context,
_carouselItems[_carouselIndex % _carouselItems.length],
colorScheme,
key: ValueKey(_carouselIndex),
),
),
),
],
);
}
Widget _buildClassClearanceSlide(
BuildContext context,
ClassClearanceProgress progress,
ColorScheme colorScheme,
{Key? key}
) {
final pct = progress.percentComplete.clamp(0, 100);
final textTheme = Theme.of(context).textTheme;
final ratio = progress.total == 0
? 0.0
: (progress.completed / progress.total).clamp(0.0, 1.0);
final activeRatio = progress.total == 0
? 0.0
: (progress.activeTotal / progress.total).clamp(0.0, 1.0);
return TweenAnimationBuilder<double>(
key: key ?? ValueKey(progress.className),
tween: Tween(begin: 0, end: ratio),
duration: const Duration(milliseconds: 1200),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
final ratioValue = ratio == 0 ? 0.0 : (value / ratio).clamp(0.0, 1.0);
final animatedHad = (progress.completed * ratioValue).round();
final animatedActive = (progress.activeTotal * ratioValue).round();
final animatedActiveRatio =
(activeRatio * ratioValue).clamp(0.0, activeRatio);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
progress.className,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'${pct.toStringAsFixed(0)}% • $animatedHad/$animatedActive/${progress.total}',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer
.withValues(alpha: 0.8),
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
height: 8,
child: Stack(
children: [
Container(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: animatedActiveRatio.isNaN
? 0
: animatedActiveRatio,
child: Container(
color:
colorScheme.onPrimaryContainer.withValues(alpha: 0.5),
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: value.isNaN ? 0 : value,
child: Container(
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
],
);
},
);
}
void _startCarouselIfNeeded(int count) {
if (count <= 1) {
_stopCarousel();
return;
}
if (_carouselItemCount != count) {
_carouselItemCount = count;
_carouselIndex = 0;
}
if (_carouselTimer != null) return;
_carouselTimer = Timer.periodic(const Duration(seconds: 8), (_) {
if (!mounted || _carouselItemCount == 0) return;
setState(() {
_carouselIndex = (_carouselIndex + 1) % _carouselItemCount;
});
});
}
void _stopCarousel() {
_carouselTimer?.cancel();
_carouselTimer = null;
}
void _refreshCarouselItems(List<ClassClearanceProgress> items) {
final signature = items
.map((item) =>
'${item.className}:${item.completed}:${item.activeTotal}:${item.total}')
.join('|');
if (signature == _carouselSignature) return;
_carouselSignature = signature;
_carouselItems = List<ClassClearanceProgress>.from(items)
..shuffle(_carouselRandom);
}
@override
void dispose() {
_stopCarousel();
super.dispose();
}
Widget _animatedMetricTile(
BuildContext context, { BuildContext context, {
required String label, required String label,
required String value, required double value,
required String Function(double) formatter,
required IconData icon, required IconData icon,
required Color color, required Color color,
}) { }) {
@@ -207,8 +401,9 @@ class _DashboardState extends State<Dashboard> {
letterSpacing: 0.4, letterSpacing: 0.4,
), ),
), ),
Text( AnimatedCountText(
value, value: value,
formatter: formatter,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: color, color: color,
@@ -555,8 +750,10 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.titleSmall style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700), ?.copyWith(fontWeight: FontWeight.w700),
), ),
Text( AnimatedCountText(
distanceUnits.format(trip.tripMileage, decimals: 1), value: trip.tripMileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
), ),
], ],
@@ -573,4 +770,31 @@ class _DashboardState extends State<Dashboard> {
String _formatTime(DateTime date) { String _formatTime(DateTime date) {
return DateFormat('HH:mm').format(date); return DateFormat('HH:mm').format(date);
} }
Future<void> _refreshDashboardData({bool force = false}) async {
final data = context.read<DataService>();
await data.fetchHomepageStats();
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
data.fetchClassClearanceProgress(limit: 75, onlyIncomplete: true),
]);
}
void _handleRouteFocus() {
final isCurrent = ModalRoute.of(context)?.isCurrent ?? true;
if (isCurrent && !_isCurrent) {
_isCurrent = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_refreshDashboardData();
});
return;
}
if (!isCurrent && _isCurrent) {
_isCurrent = false;
}
}
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
@@ -19,6 +20,9 @@ class _LegsPageState extends State<LegsPage> {
bool _initialised = false; bool _initialised = false;
bool _unallocatedOnly = false; bool _unallocatedOnly = false;
bool _showMoreFilters = false; bool _showMoreFilters = false;
bool _loadingNetworks = false;
List<String> _availableNetworks = [];
List<String> _selectedNetworks = [];
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -26,9 +30,21 @@ class _LegsPageState extends State<LegsPage> {
if (!_initialised) { if (!_initialised) {
_initialised = true; _initialised = true;
_refreshLegs(); _refreshLegs();
_loadNetworks();
} }
} }
Future<void> _loadNetworks() async {
setState(() => _loadingNetworks = true);
final data = context.read<DataService>();
await data.fetchStationNetworks();
if (!mounted) return;
setState(() {
_availableNetworks = data.stationNetworks;
_loadingNetworks = false;
});
}
Future<void> _refreshLegs() async { Future<void> _refreshLegs() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
await data.fetchLegs( await data.fetchLegs(
@@ -36,6 +52,7 @@ class _LegsPageState extends State<LegsPage> {
dateRangeStart: _formatDate(_startDate), dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
unallocatedOnly: _unallocatedOnly, unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks,
); );
} }
@@ -48,6 +65,7 @@ class _LegsPageState extends State<LegsPage> {
offset: data.legs.length, offset: data.legs.length,
append: true, append: true,
unallocatedOnly: _unallocatedOnly, unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks,
); );
} }
@@ -90,6 +108,7 @@ class _LegsPageState extends State<LegsPage> {
_sortDirection = 0; _sortDirection = 0;
_unallocatedOnly = false; _unallocatedOnly = false;
_showMoreFilters = false; _showMoreFilters = false;
_selectedNetworks = [];
}); });
_refreshLegs(); _refreshLegs();
} }
@@ -209,6 +228,16 @@ class _LegsPageState extends State<LegsPage> {
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: [ children: [
MultiSelectFilter(
label: 'Networks',
options: _availableNetworks,
selected: _selectedNetworks,
onChanged: (vals) async {
setState(() => _selectedNetworks = vals);
await _refreshLegs();
},
onRefresh: _loadingNetworks ? null : _loadNetworks,
),
FilterChip( FilterChip(
avatar: const Icon(Icons.flash_off), avatar: const Icon(Icons.flash_off),
label: const Text('Unallocated only'), label: const Text('Unallocated only'),
@@ -218,6 +247,15 @@ class _LegsPageState extends State<LegsPage> {
await _refreshLegs(); await _refreshLegs();
}, },
), ),
if (_loadingNetworks)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
], ],
), ),
), ),

View File

@@ -8,6 +8,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'loco_timeline/timeline_grid.dart'; part 'loco_timeline/timeline_grid.dart';
part 'loco_timeline/event_editor.dart'; part 'loco_timeline/event_editor.dart';
@@ -17,25 +18,41 @@ class LocoTimelinePage extends StatefulWidget {
super.key, super.key,
required this.locoId, required this.locoId,
required this.locoLabel, required this.locoLabel,
this.forceShowPending = false,
}); });
final int locoId; final int locoId;
final String locoLabel; final String locoLabel;
final bool forceShowPending;
@override @override
State<LocoTimelinePage> createState() => _LocoTimelinePageState(); State<LocoTimelinePage> createState() => _LocoTimelinePageState();
} }
class _LocoTimelinePageState extends State<LocoTimelinePage> { class _LocoTimelinePageState extends State<LocoTimelinePage> {
static const String _prefsKeyShowPending = 'timeline_show_pending';
final List<_EventDraft> _draftEvents = []; final List<_EventDraft> _draftEvents = [];
bool _isSaving = false; bool _isSaving = false;
bool _isDeleting = false; bool _isDeleting = false;
bool _isModerating = false; final Set<int> _moderatingEventIds = {};
final Set<String> _expandedPendingAttrs = {};
bool _showPending = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.forceShowPending) {
setState(() {
_showPending = true;
});
} else {
await _restorePendingVisibility();
}
if (!mounted) return;
await _load();
});
} }
dynamic _normalizeFieldValue(_FieldEntry field) { dynamic _normalizeFieldValue(_FieldEntry field) {
@@ -65,10 +82,35 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
data.fetchEventFields(); data.fetchEventFields();
return data.fetchLocoTimeline( return data.fetchLocoTimeline(
widget.locoId, widget.locoId,
includeAllPending: auth.isElevated, includeAllPending: auth.isElevated && _showPending,
); );
} }
Future<void> _restorePendingVisibility() async {
final auth = context.read<AuthService>();
if (!auth.isElevated) return;
try {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getBool(_prefsKeyShowPending);
if (saved == null) return;
if (!mounted) return;
setState(() {
_showPending = saved;
});
} catch (_) {
// Ignore preference restore failures.
}
}
Future<void> _persistPendingVisibility(bool value) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyShowPending, value);
} catch (_) {
// Ignore persistence failures.
}
}
void _addDraftEvent() { void _addDraftEvent() {
setState(() { setState(() {
_draftEvents.add(_EventDraft()); _draftEvents.add(_EventDraft());
@@ -247,7 +289,6 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
) async { ) async {
if (_isModerating) return;
final eventId = entry.sourceEventId; final eventId = entry.sourceEventId;
if (eventId == null) { if (eventId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -257,6 +298,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
); );
return; return;
} }
if (_moderatingEventIds.contains(eventId)) return;
final data = context.read<DataService>(); final data = context.read<DataService>();
final approve = action == _PendingModerationAction.approve; final approve = action == _PendingModerationAction.approve;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
@@ -283,7 +325,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
setState(() { setState(() {
_isModerating = true; _moderatingEventIds.add(eventId);
}); });
try { try {
if (approve) { if (approve) {
@@ -310,7 +352,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isModerating = false; _moderatingEventIds.remove(eventId);
}); });
} }
} }
@@ -499,7 +541,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId); final timeline = data.timelineForLoco(widget.locoId);
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
final isLoading = data.isLocoTimelineLoading(widget.locoId); final isLoading = data.isLocoTimelineLoading(widget.locoId);
final visibleTimeline = (!isElevated || _showPending)
? timeline
: timeline.where((entry) => !entry.isPending).toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -516,7 +562,32 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (isLoading && timeline.isEmpty) { if (isLoading && timeline.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (timeline.isEmpty) { if (visibleTimeline.isEmpty) {
if (timeline.isNotEmpty && isElevated && !_showPending) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pending entries hidden',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'Enable "Show pending entries" to view pending timeline blocks.',
),
],
),
),
),
);
}
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Card( child: Card(
@@ -550,15 +621,49 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (isElevated)
Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Show pending entries'),
value: _showPending,
onChanged: (value) async {
setState(() {
_showPending = value;
});
await _persistPendingVisibility(value);
if (mounted) {
await _load();
}
},
),
),
IconButton(
tooltip: 'Refresh timeline',
onPressed: _load,
icon: const Icon(Icons.refresh),
),
],
),
_TimelineGrid( _TimelineGrid(
entries: timeline, entries: visibleTimeline,
onEditEntry: (entry) => _prefillDraftFromEntry( onEditEntry: (entry) => _prefillDraftFromEntry(
entry, entry,
data.eventFields, data.eventFields,
), ),
onDeleteEntry: _deleteEntry, onDeleteEntry: _deleteEntry,
onModeratePending: _moderatePendingEntry, onModeratePending: _moderatePendingEntry,
pendingActionsBusy: _isModerating, pendingActionEventIds: _moderatingEventIds,
expandedPendingAttrs: _expandedPendingAttrs,
onTogglePendingAttr: (attrCode) {
setState(() {
if (!_expandedPendingAttrs.add(attrCode)) {
_expandedPendingAttrs.remove(attrCode);
}
});
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_EventEditor( _EventEditor(

View File

@@ -10,7 +10,9 @@ class _TimelineGrid extends StatefulWidget {
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending, this.onModeratePending,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
this.expandedPendingAttrs = const {},
this.onTogglePendingAttr,
}); });
final List<LocoAttrVersion> entries; final List<LocoAttrVersion> entries;
@@ -20,7 +22,9 @@ class _TimelineGrid extends StatefulWidget {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
)? onModeratePending; )? onModeratePending;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
final Set<String> expandedPendingAttrs;
final void Function(String attrCode)? onTogglePendingAttr;
@override @override
State<_TimelineGrid> createState() => _TimelineGridState(); State<_TimelineGrid> createState() => _TimelineGridState();
@@ -86,12 +90,15 @@ class _TimelineGridState extends State<_TimelineGrid> {
'build_day', 'build_day',
}.contains(code); }.contains(code);
}).toList(); }).toList();
final model = _TimelineModel.fromEntries(filteredEntries); final model = _TimelineModel.fromEntries(
filteredEntries,
expandedAttrCodes: widget.expandedPendingAttrs,
);
final axisSegments = model.axisSegments; final axisSegments = model.axisSegments;
const labelWidth = 110.0; const labelWidth = 110.0;
const rowHeight = 52.0; const rowHeight = 52.0;
const double axisHeight = 48; const double axisHeight = 48;
final rows = model.attrRows.entries.toList(); final rows = model.rows;
final totalRowsHeight = rows.length * rowHeight; final totalRowsHeight = rows.length * rowHeight;
final axisWidth = math.max(model.axisTotalWidth, 120.0); final axisWidth = math.max(model.axisTotalWidth, 120.0);
final double viewHeight = totalRowsHeight + axisHeight + 8; final double viewHeight = totalRowsHeight + axisHeight + 8;
@@ -131,7 +138,12 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight, itemExtent: rowHeight,
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final label = _formatAttrLabel(rows[index].key); final row = rows[index];
final label = row.isPrimary
? _formatAttrLabel(row.attrCode)
: (row.pendingUser?.trim().isNotEmpty == true
? row.pendingUser!.trim()
: 'Unknown');
return Container( return Container(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -148,12 +160,49 @@ class _TimelineGridState extends State<_TimelineGrid> {
), ),
), ),
), ),
child: Text( child: Row(
label, children: [
style: Theme.of(context) if (!row.isPrimary) ...[
.textTheme Icon(
.labelLarge Icons.subdirectory_arrow_right,
?.copyWith(fontWeight: FontWeight.w700), size: 16,
color: Theme.of(context).hintColor,
),
const SizedBox(width: 6),
],
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
fontWeight: row.isPrimary
? FontWeight.w700
: FontWeight.w600,
),
),
),
if (row.showExpandToggle)
IconButton(
onPressed: widget.onTogglePendingAttr == null
? null
: () => widget.onTogglePendingAttr?.call(
row.attrCode,
),
icon: Icon(
row.isExpanded
? Icons.expand_less
: Icons.expand_more,
),
tooltip: row.isExpanded
? 'Collapse pending rows'
: 'Expand pending rows',
visualDensity: VisualDensity.compact,
),
],
), ),
); );
}, },
@@ -188,7 +237,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight, itemExtent: rowHeight,
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final blocks = rows[index].value; final blocks = rows[index].blocks;
return Padding( return Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 2.0), const EdgeInsets.symmetric(vertical: 2.0),
@@ -201,7 +250,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
onEditEntry: widget.onEditEntry, onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry, onDeleteEntry: widget.onDeleteEntry,
onModeratePending: widget.onModeratePending, onModeratePending: widget.onModeratePending,
pendingActionsBusy: widget.pendingActionsBusy, pendingActionEventIds: widget.pendingActionEventIds,
), ),
); );
}, },
@@ -288,7 +337,7 @@ class _AttrRow extends StatelessWidget {
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending, this.onModeratePending,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
}); });
final double rowHeight; final double rowHeight;
@@ -302,7 +351,7 @@ class _AttrRow extends StatelessWidget {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
)? onModeratePending; )? onModeratePending;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -329,7 +378,7 @@ class _AttrRow extends StatelessWidget {
onEditEntry: onEditEntry, onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry, onDeleteEntry: onDeleteEntry,
onModeratePending: onModeratePending, onModeratePending: onModeratePending,
pendingActionsBusy: pendingActionsBusy, pendingActionEventIds: pendingActionEventIds,
), ),
), ),
if (activeBlock != null) if (activeBlock != null)
@@ -346,7 +395,7 @@ class _AttrRow extends StatelessWidget {
width: stickyWidth, width: stickyWidth,
), ),
clipLeftEdge: scrollOffset > activeBlock.left + 0.1, clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
pendingActionsBusy: pendingActionsBusy, pendingActionEventIds: pendingActionEventIds,
), ),
), ),
), ),
@@ -368,12 +417,12 @@ class _ValueBlockView extends StatelessWidget {
const _ValueBlockView({ const _ValueBlockView({
required this.block, required this.block,
this.clipLeftEdge = false, this.clipLeftEdge = false,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
}); });
final _ValueBlock block; final _ValueBlock block;
final bool clipLeftEdge; final bool clipLeftEdge;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -384,6 +433,11 @@ class _ValueBlockView extends StatelessWidget {
? Colors.white ? Colors.white
: Colors.black87; : Colors.black87;
final entry = block.entry;
final eventId = entry?.sourceEventId;
final isPendingAction =
entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId);
final radius = BorderRadius.only( final radius = BorderRadius.only(
topLeft: Radius.circular(clipLeftEdge ? 0 : 12), topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
@@ -425,7 +479,7 @@ class _ValueBlockView extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: pendingActionsBusy child: isPendingAction
? CircularProgressIndicator( ? CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: valueColor:
@@ -484,7 +538,7 @@ class _ValueBlockMenu extends StatelessWidget {
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending, this.onModeratePending,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
}); });
final _ValueBlock block; final _ValueBlock block;
@@ -494,7 +548,7 @@ class _ValueBlockMenu extends StatelessWidget {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
)? onModeratePending; )? onModeratePending;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
bool get _hasActions { bool get _hasActions {
final canModerate = block.entry?.isPending == true && final canModerate = block.entry?.isPending == true &&
@@ -515,6 +569,9 @@ class _ValueBlockMenu extends StatelessWidget {
block.entry?.canModeratePending == true && block.entry?.canModeratePending == true &&
onModeratePending != null; onModeratePending != null;
final canEdit = onEditEntry != null && block.entry?.isPending != true; final canEdit = onEditEntry != null && block.entry?.isPending != true;
final eventId = block.entry?.sourceEventId;
final isPendingAction =
eventId != null && pendingActionEventIds.contains(eventId);
Future<void> showContextMenuAt(Offset globalPosition) async { Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context); final overlay = Overlay.of(context);
@@ -540,13 +597,13 @@ class _ValueBlockMenu extends StatelessWidget {
if (canModerate) if (canModerate)
PopupMenuItem( PopupMenuItem(
value: _TimelineBlockAction.approve, value: _TimelineBlockAction.approve,
enabled: !pendingActionsBusy, enabled: !isPendingAction,
child: const Text('Approve pending'), child: const Text('Approve pending'),
), ),
if (canModerate) if (canModerate)
PopupMenuItem( PopupMenuItem(
value: _TimelineBlockAction.reject, value: _TimelineBlockAction.reject,
enabled: !pendingActionsBusy, enabled: !isPendingAction,
child: const Text('Reject pending'), child: const Text('Reject pending'),
), ),
if (onDeleteEntry != null) if (onDeleteEntry != null)
@@ -588,7 +645,7 @@ class _ValueBlockMenu extends StatelessWidget {
}, },
child: _ValueBlockView( child: _ValueBlockView(
block: block, block: block,
pendingActionsBusy: pendingActionsBusy, pendingActionEventIds: pendingActionEventIds,
), ),
); );
} }
@@ -649,79 +706,310 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
return end; return end;
} }
int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day;
bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
final start = _effectiveStart(entry);
if (start == null) return false;
return approvedStartKeys.contains(_startKey(start));
}
List<_ValueSegment> _segmentsForEntries(
List<LocoAttrVersion> items,
DateTime now, {
bool? clampToNextStart,
}) {
if (items.isEmpty) return const [];
final hasPending = items.any((e) => e.isPending);
final hasApproved = items.any((e) => !e.isPending);
final shouldClamp =
clampToNextStart ?? (hasPending && hasApproved);
final sorted = [...items];
sorted.sort(
(a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now),
);
final segments = <_ValueSegment>[];
for (int i = 0; i < sorted.length; i++) {
final entry = sorted[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < sorted.length - 1
? _effectiveStart(sorted[i + 1])
: null;
DateTime? rawEnd = entry.validTo;
if (nextStart != null) {
if (rawEnd == null || (shouldClamp && nextStart.isBefore(rawEnd))) {
rawEnd = nextStart;
}
}
rawEnd ??= now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
start: start,
end: end,
value: _formatValueWithUnits(entry),
entry: entry,
),
);
}
return segments;
}
List<LocoAttrVersion> _applyPendingOverrides(
List<LocoAttrVersion> approved,
List<LocoAttrVersion> pending,
) {
if (pending.isEmpty) return approved;
final pendingByStart = <int, LocoAttrVersion>{};
final extraPending = <LocoAttrVersion>[];
for (final entry in pending) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
pendingByStart[key] = entry;
}
final applied = <LocoAttrVersion>[];
final seenKeys = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
if (pendingByStart.containsKey(key)) {
if (!seenKeys.contains(key)) {
applied.add(pendingByStart[key]!);
seenKeys.add(key);
}
} else {
applied.add(entry);
seenKeys.add(key);
}
}
for (final entry in pendingByStart.values) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
if (!seenKeys.contains(key)) {
extraPending.add(entry);
seenKeys.add(key);
}
}
if (extraPending.isNotEmpty) {
applied.addAll(extraPending);
}
return applied;
}
List<DateTime> _buildBoundaries(
List<_ValueSegment> segments,
DateTime now,
) {
DateTime? minStart;
DateTime? maxEnd;
final boundaryDates = <DateTime>{};
for (final seg in segments) {
boundaryDates.add(seg.start);
boundaryDates.add(seg.end);
minStart = minStart == null || seg.start.isBefore(minStart)
? seg.start
: minStart;
maxEnd = maxEnd == null || seg.end.isAfter(maxEnd) ? seg.end : maxEnd;
}
final effectiveMinStart = minStart ?? now.subtract(const Duration(days: 1));
final effectiveMaxEnd = maxEnd ?? now;
boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) {
boundaries = [effectiveMinStart, effectiveMaxEnd];
}
return boundaries;
}
class _TimelineRowSpec {
final String id;
final String attrCode;
final List<_ValueSegment> segments;
final bool isPrimary;
final bool showExpandToggle;
final bool isExpanded;
final String? userLabel;
const _TimelineRowSpec._({
required this.id,
required this.attrCode,
required this.segments,
required this.isPrimary,
required this.showExpandToggle,
required this.isExpanded,
this.userLabel,
});
factory _TimelineRowSpec.primary({
required String attrCode,
required List<_ValueSegment> segments,
required bool showExpandToggle,
required bool isExpanded,
}) {
return _TimelineRowSpec._(
id: attrCode,
attrCode: attrCode,
segments: segments,
isPrimary: true,
showExpandToggle: showExpandToggle,
isExpanded: isExpanded,
);
}
factory _TimelineRowSpec.pending({
required String attrCode,
required String userLabel,
required List<_ValueSegment> segments,
}) {
return _TimelineRowSpec._(
id: '$attrCode::$userLabel',
attrCode: attrCode,
segments: segments,
isPrimary: false,
showExpandToggle: false,
isExpanded: false,
userLabel: userLabel,
);
}
}
class _TimelineRowData {
final String id;
final String attrCode;
final List<_ValueBlock> blocks;
final bool isPrimary;
final bool showExpandToggle;
final bool isExpanded;
final String? pendingUser;
const _TimelineRowData({
required this.id,
required this.attrCode,
required this.blocks,
required this.isPrimary,
required this.showExpandToggle,
required this.isExpanded,
this.pendingUser,
});
}
class _TimelineModel { class _TimelineModel {
final List<_AxisSegment> axisSegments; final List<_AxisSegment> axisSegments;
final Map<String, List<_ValueBlock>> attrRows; final List<_TimelineRowData> rows;
final String endLabel; final String endLabel;
final List<DateTime> boundaries; final List<DateTime> boundaries;
final double axisTotalWidth; final double axisTotalWidth;
_TimelineModel({ _TimelineModel({
required this.axisSegments, required this.axisSegments,
required this.attrRows, required this.rows,
required this.endLabel, required this.endLabel,
required this.boundaries, required this.boundaries,
required this.axisTotalWidth, required this.axisTotalWidth,
}); });
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) { factory _TimelineModel.fromEntries(
List<LocoAttrVersion> entries, {
Set<String> expandedAttrCodes = const {},
}) {
final effectiveEntries = entries final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null) .where((e) => _effectiveStart(e) != null)
.toList(); .toList();
final grouped = <String, List<LocoAttrVersion>>{}; final grouped = <String, List<LocoAttrVersion>>{};
final attrOrder = <String>[];
for (final entry in effectiveEntries) { for (final entry in effectiveEntries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry); final key = entry.attrCode;
if (!grouped.containsKey(key)) {
attrOrder.add(key);
}
grouped.putIfAbsent(key, () => []).add(entry);
} }
final now = DateTime.now(); final now = DateTime.now();
DateTime? minStart; final allSegments = <_ValueSegment>[];
DateTime? maxEnd; final rowSpecs = <_TimelineRowSpec>[];
final attrSegments = <String, List<_ValueSegment>>{}; for (final attr in attrOrder) {
final items = grouped[attr] ?? const [];
final approved = items.where((e) => !e.isPending).toList();
final pending = items.where((e) => e.isPending).toList();
final approvedSegments = _segmentsForEntries(approved, now);
grouped.forEach((attr, items) { final approvedStartKeys = <int>{};
items.sort( for (final entry in approved) {
(a, b) => (_effectiveStart(a) ?? now) final start = _effectiveStart(entry);
.compareTo(_effectiveStart(b) ?? now), if (start == null) continue;
approvedStartKeys.add(_startKey(start));
}
final pendingByUser = <String, List<LocoAttrVersion>>{};
final overlapByUser = <String, List<LocoAttrVersion>>{};
for (final entry in pending) {
final user = (entry.suggestedBy ?? '').trim().isEmpty
? 'Unknown'
: entry.suggestedBy!.trim();
pendingByUser.putIfAbsent(user, () => []).add(entry);
final start = _effectiveStart(entry);
if (start == null) continue;
if (approvedStartKeys.contains(_startKey(start))) {
overlapByUser.putIfAbsent(user, () => []).add(entry);
}
}
final hasOverlap = overlapByUser.isNotEmpty;
final canToggle = pending.isNotEmpty && !hasOverlap;
final isExpanded = expandedAttrCodes.contains(attr);
final shouldShowPendingRows = isExpanded || hasOverlap;
final nonOverlapPending =
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
final baseEntries =
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
final baseSegments = shouldShowPendingRows
? approvedSegments
: _segmentsForEntries(baseEntries, now);
rowSpecs.add(
_TimelineRowSpec.primary(
attrCode: attr,
segments: baseSegments,
showExpandToggle: canToggle,
isExpanded: isExpanded,
),
); );
final segments = <_ValueSegment>[]; allSegments.addAll(baseSegments);
for (int i = 0; i < items.length; i++) {
final entry = items[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
start: start,
end: end,
value: _formatValueWithUnits(entry),
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
}
attrSegments[attr] = segments;
});
minStart ??= now.subtract(const Duration(days: 1)); if (shouldShowPendingRows) {
final effectiveMaxEnd = maxEnd ?? now; final users = isExpanded
? pendingByUser.keys.toList()
final boundaryDates = <DateTime>{}; : overlapByUser.keys.toList();
for (final segments in attrSegments.values) { users.sort();
for (final seg in segments) { for (final user in users) {
boundaryDates.add(seg.start); final pendingEntries = isExpanded
boundaryDates.add(seg.end); ? (pendingByUser[user] ?? const [])
: (overlapByUser[user] ?? const []);
if (pendingEntries.isEmpty) continue;
final appliedEntries =
_applyPendingOverrides(approved, pendingEntries);
final combinedSegments = _segmentsForEntries(appliedEntries, now);
rowSpecs.add(
_TimelineRowSpec.pending(
attrCode: attr,
userLabel: user,
segments: combinedSegments,
),
);
allSegments.addAll(combinedSegments);
}
} }
} }
boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort(); final boundaries = _buildBoundaries(allSegments, now);
if (boundaries.length < 2) {
boundaries = [minStart!, effectiveMaxEnd];
}
final axisSegments = <_AxisSegment>[]; final axisSegments = <_AxisSegment>[];
const double yearWidth = 240.0; const double yearWidth = 240.0;
@@ -745,10 +1033,10 @@ class _TimelineModel {
final axisTotalWidth = final axisTotalWidth =
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width); axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{}; final rows = <_TimelineRowData>[];
for (final entry in attrSegments.entries) { for (final spec in rowSpecs) {
final blocks = <_ValueBlock>[]; final blocks = <_ValueBlock>[];
for (final seg in entry.value) { for (final seg in spec.segments) {
final left = _positionForDate(seg.start, boundaries, axisSegments); final left = _positionForDate(seg.start, boundaries, axisSegments);
final right = _positionForDate(seg.end, boundaries, axisSegments); final right = _positionForDate(seg.end, boundaries, axisSegments);
final span = right - left; final span = right - left;
@@ -762,13 +1050,24 @@ class _TimelineModel {
), ),
); );
} }
attrRows[entry.key] = blocks; rows.add(
_TimelineRowData(
id: spec.id,
attrCode: spec.attrCode,
blocks: blocks,
isPrimary: spec.isPrimary,
showExpandToggle: spec.showExpandToggle,
isExpanded: spec.isExpanded,
pendingUser: spec.userLabel,
),
);
} }
final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; final endLabel =
boundaries.isNotEmpty ? _formatDate(boundaries.last) ?? 'Now' : 'Now';
return _TimelineModel( return _TimelineModel(
axisSegments: axisSegments, axisSegments: axisSegments,
attrRows: attrRows, rows: rows,
endLabel: endLabel, endLabel: endLabel,
boundaries: boundaries, boundaries: boundaries,
axisTotalWidth: axisTotalWidth, axisTotalWidth: axisTotalWidth,

View File

@@ -130,11 +130,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
int decimals = 1, int decimals = 1,
bool includeUnit = true, bool includeUnit = true,
}) { }) {
return units.format( return units.format(miles, decimals: decimals, includeUnit: includeUnit);
miles,
decimals: decimals,
includeUnit: includeUnit,
);
} }
String _manualMileageLabel(DistanceUnit unit) { String _manualMileageLabel(DistanceUnit unit) {
@@ -191,15 +187,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
double _milesFromInputWithUnit(DistanceUnit unit) { double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit) return DistanceFormatter(
.parseInputMiles(_mileageController.text.trim()) ?? unit,
).parseInputMiles(_mileageController.text.trim()) ??
0; 0;
} }
List<UserSummary> _friendsFromFriendships( List<UserSummary> _friendsFromFriendships(DataService data, String? selfId) {
DataService data,
String? selfId,
) {
final friends = <UserSummary>[]; final friends = <UserSummary>[];
for (final f in data.friendships) { for (final f in data.friendships) {
final other = _friendFromFriendship(f, selfId); final other = _friendFromFriendship(f, selfId);
@@ -300,9 +294,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (!mounted) return; if (!mounted) return;
final baseFriends = _friendsFromFriendships(data, auth.userId); final baseFriends = _friendsFromFriendships(data, auth.userId);
final initialSelectedIds = {..._shareUserIds}; final initialSelectedIds = {..._shareUserIds};
final initialSelectedUsers = { final initialSelectedUsers = {for (final u in _shareUsers) u.userId: u};
for (final u in _shareUsers) u.userId: u,
};
final result = await showModalBottomSheet<List<UserSummary>>( final result = await showModalBottomSheet<List<UserSummary>>(
context: context, context: context,
@@ -334,8 +326,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
searchError = null; searchError = null;
}); });
try { try {
final results = final results = await data.searchUsers(
await data.searchUsers(trimmed, friendsOnly: true); trimmed,
friendsOnly: true,
);
setModalState(() { setModalState(() {
searchResults = results; searchResults = results;
}); });
@@ -414,8 +408,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
itemCount: list.length, itemCount: list.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final user = list[index]; final user = list[index];
final isSelected = final isSelected = selectedIds.contains(
selectedIds.contains(user.userId); user.userId,
);
return CheckboxListTile( return CheckboxListTile(
value: isSelected, value: isSelected,
title: Text(user.displayName), title: Text(user.displayName),
@@ -481,8 +476,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (previousUnit == currentUnit) return; if (previousUnit == currentUnit) return;
final miles = _milesFromInputWithUnit(previousUnit); final miles = _milesFromInputWithUnit(previousUnit);
final nextText = DistanceFormatter(currentUnit) final nextText = DistanceFormatter(
.format(miles, decimals: 2, includeUnit: false); currentUnit,
).format(miles, decimals: 2, includeUnit: false);
_mileageController.text = nextText; _mileageController.text = nextText;
_lastDistanceUnit = currentUnit; _lastDistanceUnit = currentUnit;
} }
@@ -842,8 +838,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
final destination = json['leg_destination'] as String? ?? ''; final destination = json['leg_destination'] as String? ?? '';
final hasEndTime = endTime != null || endDelay != 0; final hasEndTime = endTime != null || endDelay != 0;
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? ''); final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
final destinationTime = final destinationTime = DateTime.tryParse(
DateTime.tryParse(json['leg_destination_time'] ?? ''); json['leg_destination_time'] ?? '',
);
final hasOriginTime = originTime != null; final hasOriginTime = originTime != null;
final hasDestinationTime = destinationTime != null; final hasDestinationTime = destinationTime != null;
@@ -860,8 +857,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime; _selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime); _selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime; _selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = _selectedDestinationTime = TimeOfDay.fromDateTime(
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime); destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime; _hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime; _hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual; _useManualMileage = useManual;
@@ -927,18 +925,20 @@ class _NewEntryPageState extends State<NewEntryPage> {
); );
final tractionItems = _buildTractionFromApi( final tractionItems = _buildTractionFromApi(
entry.locos entry.locos
.map((l) => { .map(
"loco_id": l.id, (l) => {
"type": l.type, "loco_id": l.id,
"number": l.number, "type": l.type,
"class": l.locoClass, "number": l.number,
"name": l.name, "class": l.locoClass,
"operator": l.operator, "name": l.name,
"notes": l.notes, "operator": l.operator,
"evn": l.evn, "notes": l.notes,
"alloc_pos": l.allocPos, "evn": l.evn,
"alloc_powering": l.powering ? 1 : 0, "alloc_pos": l.allocPos,
}) "alloc_powering": l.powering ? 1 : 0,
},
)
.toList(), .toList(),
); );
final beginDelay = entry.beginDelayMinutes ?? 0; final beginDelay = entry.beginDelayMinutes ?? 0;
@@ -963,8 +963,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime; _selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime); _selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime; _selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = _selectedDestinationTime = TimeOfDay.fromDateTime(
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime); destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime; _hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime; _hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual; _useManualMileage = useManual;
@@ -980,12 +981,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
_endDelayController.text = endDelay.toString(); _endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0 _mileageController.text = mileageVal == 0
? '' ? ''
: _formatDistance( : _formatDistance(units, mileageVal, decimals: 2, includeUnit: false);
units,
mileageVal,
decimals: 2,
includeUnit: false,
);
_tractionItems _tractionItems
..clear() ..clear()
..addAll(tractionItems); ..addAll(tractionItems);
@@ -1187,10 +1183,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
), ),
], ],
if (!matchValue) ...[ if (!matchValue) ...[
_stationField( _stationField(label: label, controller: controller),
label: label,
controller: controller,
),
CheckboxListTile( CheckboxListTile(
value: hasTime, value: hasTime,
onChanged: onTimeChanged, onChanged: onTimeChanged,
@@ -1237,8 +1230,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: onPressed: _isEditing || _activeLegShare != null
_isEditing || _activeLegShare != null ? null : _openDrafts, ? null
: _openDrafts,
icon: const Icon(Icons.list_alt, size: 16), icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'), label: const Text('Drafts'),
), ),
@@ -1246,26 +1240,27 @@ class _NewEntryPageState extends State<NewEntryPage> {
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: _isEditing || onPressed:
_savingDraft || _isEditing ||
_submitting || _savingDraft ||
_activeLegShare != null _submitting ||
? null _activeLegShare != null
: _saveDraftManually, ? null
icon: _savingDraft : _saveDraftManually,
? const SizedBox( icon: _savingDraft
width: 16, ? const SizedBox(
height: 16, width: 16,
child: CircularProgressIndicator(strokeWidth: 2), height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.save_alt, size: 16), : const Icon(Icons.save_alt, size: 16),
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'), label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
), ),
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
@@ -1276,17 +1271,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
: () => _resetFormState(clearDraft: true), : () => _resetFormState(clearDraft: true),
icon: const Icon(Icons.clear, size: 16), icon: const Icon(Icons.clear, size: 16),
label: const Text('Clear form'), label: const Text('Clear form'),
),
],
), ),
], if (_activeLegShare != null) ...[
), const SizedBox(height: 8),
if (_activeLegShare != null) ...[ _buildSharedBanner(),
const SizedBox(height: 8), ],
_buildSharedBanner(),
],
_buildTripSelector(context), _buildTripSelector(context),
const SizedBox(height: 8), const SizedBox(height: 8),
if (_activeLegShare == null) _buildShareSection(context), if (_activeLegShare == null) _buildShareSection(context),
_dateTimeGroup( _dateTimeGroup(
context, context,
title: 'Departure time', title: 'Departure time',
onDateTap: _pickDate, onDateTap: _pickDate,
@@ -1393,8 +1388,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
onTimeChanged: _submitting ? null : _toggleDestinationTime, onTimeChanged: _submitting ? null : _toggleDestinationTime,
matchLabel: 'Match entry end', matchLabel: 'Match entry end',
matchValue: _matchDestinationToEntry, matchValue: _matchDestinationToEntry,
onMatchChanged: onMatchChanged: _submitting ? null : _toggleMatchDestination,
_submitting ? null : _toggleMatchDestination,
pickerBuilder: () => _dateTimeGroupSimple( pickerBuilder: () => _dateTimeGroupSimple(
context, context,
title: 'Destination arrival', title: 'Destination arrival',
@@ -1405,16 +1399,18 @@ class _NewEntryPageState extends State<NewEntryPage> {
singleColumn: true, singleColumn: true,
), ),
), ),
const Divider(height: 24), if (_useManualMileage) ...[
TextFormField( const Divider(height: 24),
controller: _networkController, TextFormField(
textCapitalization: TextCapitalization.characters, controller: _networkController,
inputFormatters: const [_UpperCaseTextFormatter()], textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration( inputFormatters: const [_UpperCaseTextFormatter()],
labelText: 'Network', decoration: const InputDecoration(
border: OutlineInputBorder(), labelText: 'Network',
border: OutlineInputBorder(),
),
), ),
), ],
TextFormField( TextFormField(
controller: _notesController, controller: _notesController,
maxLines: 3, maxLines: 3,
@@ -1428,7 +1424,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final tractionPanel = _section('Traction', [ final tractionPanel = _section('Traction', [
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: ElevatedButton.icon( child: FilledButton.icon(
onPressed: _openTractionPicker, onPressed: _openTractionPicker,
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
label: const Text('Search traction'), label: const Text('Search traction'),
@@ -1440,13 +1436,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.first ? _routeResult!.calculatedRoute.first
: (_routeResult?.inputRoute.isNotEmpty == true : (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.first ? _routeResult!.inputRoute.first
: _startController.text.trim()); : _startController.text.trim());
final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.last ? _routeResult!.calculatedRoute.last
: (_routeResult?.inputRoute.isNotEmpty == true : (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.last ? _routeResult!.inputRoute.last
: _endController.text.trim()); : _endController.text.trim());
final mileagePanel = _section( final mileagePanel = _section(
'Your Journey', 'Your Journey',
@@ -1456,12 +1452,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton.icon( FilledButton.icon(
onPressed: _openCalculator, onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18), icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'), label: const Text('Open mileage calculator'),
), ),
OutlinedButton.icon( TextButton.icon(
onPressed: _reverseRouteAndEndpoints, onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz), icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'), label: const Text('Reverse route'),
@@ -1493,8 +1489,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
), ),
decoration: InputDecoration( decoration: InputDecoration(
labelText: mileageLabel, labelText: mileageLabel,
helperText: currentDistanceUnit == helperText:
DistanceUnit.milesChains currentDistanceUnit == DistanceUnit.milesChains
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)' ? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
: null, : null,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
@@ -1526,6 +1522,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
onSelected: (val) { onSelected: (val) {
setState(() { setState(() {
_useManualMileage = val; _useManualMileage = val;
if (!val) {
_networkController.clear();
}
if (val && _routeResult != null) { if (val && _routeResult != null) {
_mileageController.text = _formatDistance( _mileageController.text = _formatDistance(
distanceUnitService, distanceUnitService,
@@ -1571,7 +1570,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton.icon( FilledButton.icon(
onPressed: _submitting ? null : _submit, onPressed: _submitting ? null : _submit,
icon: _submitting icon: _submitting
? const SizedBox( ? const SizedBox(
@@ -1783,52 +1782,52 @@ class _NewEntryPageState extends State<NewEntryPage> {
}, },
fieldViewBuilder: fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) { (context, textEditingController, focusNode, onFieldSubmitted) {
if (textEditingController.text != controller.text) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (textEditingController.text != controller.text) { if (textEditingController.text != controller.text) {
textEditingController.value = controller.value; WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (textEditingController.text != controller.text) {
textEditingController.value = controller.value;
}
});
} }
}); return TextFormField(
} controller: textEditingController,
return TextFormField( focusNode: focusNode,
controller: textEditingController, textCapitalization: TextCapitalization.words,
focusNode: focusNode, decoration: InputDecoration(
textCapitalization: TextCapitalization.words, labelText: label,
decoration: InputDecoration( border: const OutlineInputBorder(),
labelText: label, suffixIcon: _loadingStations
border: const OutlineInputBorder(), ? const SizedBox(
suffixIcon: _loadingStations width: 20,
? const SizedBox( height: 20,
width: 20, child: Padding(
height: 20, padding: EdgeInsets.all(4.0),
child: Padding( child: CircularProgressIndicator(strokeWidth: 2),
padding: EdgeInsets.all(4.0), ),
child: CircularProgressIndicator(strokeWidth: 2), )
), : const Icon(Icons.search),
) ),
: const Icon(Icons.search), textInputAction: TextInputAction.done,
), onChanged: (_) {
textInputAction: TextInputAction.done, controller.value = textEditingController.value;
onChanged: (_) { _saveDraft();
controller.value = textEditingController.value; },
_saveDraft(); onFieldSubmitted: (_) {
final matches = _matchStations(
textEditingController.text,
stationNames,
).toList();
if (matches.isNotEmpty) {
final top = matches.first;
controller.text = top;
textEditingController.text = top;
_saveDraft();
}
focusNode.unfocus();
},
);
}, },
onFieldSubmitted: (_) {
final matches = _matchStations(
textEditingController.text,
stationNames,
).toList();
if (matches.isNotEmpty) {
final top = matches.first;
controller.text = top;
textEditingController.text = top;
_saveDraft();
}
focusNode.unfocus();
},
);
},
); );
} }
@@ -1842,7 +1841,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
return best; return best;
} }
void _insertCandidate(List<String> best, String candidate, {required int max}) { void _insertCandidate(
List<String> best,
String candidate, {
required int max,
}) {
final existingIndex = best.indexOf(candidate); final existingIndex = best.indexOf(candidate);
if (existingIndex >= 0) return; if (existingIndex >= 0) return;
@@ -2016,7 +2019,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
], ],
); );
} }
} }
class _UpperCaseTextFormatter extends TextInputFormatter { class _UpperCaseTextFormatter extends TextInputFormatter {

View File

@@ -18,7 +18,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
} }
if (_networkController.text.trim().isEmpty) { if (_useManualMileage && _networkController.text.trim().isEmpty) {
missing.add('Network'); missing.add('Network');
} }
@@ -93,7 +93,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final isEditingExisting = _isEditing && widget.editLegId != null; final isEditingExisting = _isEditing && widget.editLegId != null;
try { try {
final commonPayload = { final commonPayload = {
if (isEditingExisting) "leg_id": widget.editLegId, if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId, "leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(), "leg_begin_time": _legDateTime.toIso8601String(),
@@ -104,7 +104,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"leg_destination_time": destinationTime.toIso8601String(), "leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(), "leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(), "leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(), if (_useManualMileage)
"leg_network": _networkController.text.trim(),
"leg_origin": _originController.text.trim(), "leg_origin": _originController.text.trim(),
"leg_destination": _destinationController.text.trim(), "leg_destination": _destinationController.text.trim(),
"leg_begin_delay": beginDelay, "leg_begin_delay": beginDelay,

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -5,6 +7,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
class ProfilePage extends StatefulWidget { class ProfilePage extends StatefulWidget {
const ProfilePage({super.key}); const ProfilePage({super.key});
@@ -31,6 +34,10 @@ class _ProfilePageState extends State<ProfilePage> {
bool _showAccountSettings = false; bool _showAccountSettings = false;
bool _changingPassword = false; bool _changingPassword = false;
static const List<String> _visibilityOptions = ['private', 'friends', 'public']; static const List<String> _visibilityOptions = ['private', 'friends', 'public'];
static const List<String> _exportFormats = ['xlsx', 'json', 'csv'];
bool _exporting = false;
String _exportFormat = 'xlsx';
String? _exportError;
UserSummary? _selectedUser; UserSummary? _selectedUser;
Friendship? _status; Friendship? _status;
@@ -71,6 +78,54 @@ class _ProfilePageState extends State<ProfilePage> {
super.dispose(); super.dispose();
} }
Future<void> _exportLogEntries() async {
if (_exporting) return;
setState(() {
_exporting = true;
_exportError = null;
});
try {
final data = context.read<DataService>();
final response = await data.api.getBytes(
'/legs/export?export_format=$_exportFormat',
headers: const {'accept': '*/*'},
);
final timestamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '')
.replaceAll('.', '');
final fallbackName = 'log_entries_$timestamp.$_exportFormat';
final filename = response.filename ?? fallbackName;
final saveResult = await saveBytes(
Uint8List.fromList(response.bytes),
filename,
mimeType: response.contentType,
);
if (!mounted) return;
if (saveResult.canceled) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export canceled.')),
);
} else {
final path = saveResult.path;
final message = path == null || path.isEmpty
? 'Download started.'
: 'Export saved to $path';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _exportError = e.message);
} catch (e) {
if (!mounted) return;
setState(() => _exportError = e.toString());
} finally {
if (mounted) setState(() => _exporting = false);
}
}
Future<void> _searchUsers() async { Future<void> _searchUsers() async {
final query = _searchController.text.trim(); final query = _searchController.text.trim();
if (query.isEmpty) { if (query.isEmpty) {
@@ -127,6 +182,9 @@ class _ProfilePageState extends State<ProfilePage> {
); );
if (!mounted) return; if (!mounted) return;
setState(() => _status = status); setState(() => _status = status);
final data = context.read<DataService>();
await data.fetchFriendships();
await data.fetchPendingFriendships();
_showSnack('Friend request sent'); _showSnack('Friend request sent');
} catch (e) { } catch (e) {
_showSnack('Failed to send request: $e'); _showSnack('Failed to send request: $e');
@@ -159,6 +217,7 @@ class _ProfilePageState extends State<ProfilePage> {
final updated = await context.read<DataService>().acceptFriendship(id); final updated = await context.read<DataService>().acceptFriendship(id);
if (!mounted) return; if (!mounted) return;
setState(() => _status = updated); setState(() => _status = updated);
await context.read<DataService>().fetchFriendships();
_showSnack('Friend request accepted'); _showSnack('Friend request accepted');
} catch (e) { } catch (e) {
_showSnack('Failed to accept: $e'); _showSnack('Failed to accept: $e');
@@ -175,6 +234,7 @@ class _ProfilePageState extends State<ProfilePage> {
final updated = await context.read<DataService>().rejectFriendship(id); final updated = await context.read<DataService>().rejectFriendship(id);
if (!mounted) return; if (!mounted) return;
setState(() => _status = updated); setState(() => _status = updated);
await context.read<DataService>().fetchPendingFriendships();
_showSnack('Friend request rejected'); _showSnack('Friend request rejected');
} catch (e) { } catch (e) {
_showSnack('Failed to reject: $e'); _showSnack('Failed to reject: $e');
@@ -824,6 +884,8 @@ class _ProfilePageState extends State<ProfilePage> {
firstChild: Column( firstChild: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildExportSection(theme),
const SizedBox(height: 12),
if (showPrivacySpinner) if (showPrivacySpinner)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0), padding: EdgeInsets.symmetric(vertical: 8.0),
@@ -1018,6 +1080,60 @@ class _ProfilePageState extends State<ProfilePage> {
); );
} }
Widget _buildExportSection(ThemeData theme) {
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(top: 8),
title: Text(
'Export log entries',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
subtitle: const Text('Download your log as a file.'),
children: [
DropdownButtonFormField<String>(
value: _exportFormat,
decoration: const InputDecoration(
labelText: 'Export format',
border: OutlineInputBorder(),
),
items: _exportFormats
.map(
(format) => DropdownMenuItem(
value: format,
child: Text(format.toUpperCase()),
),
)
.toList(),
onChanged: _exporting
? null
: (value) {
if (value == null) return;
setState(() => _exportFormat = value);
},
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _exporting ? null : _exportLogEntries,
icon: _exporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text(_exporting ? 'Exporting...' : 'Export'),
),
if (_exportError != null) ...[
const SizedBox(height: 8),
Text(
_exportError!,
style: TextStyle(color: theme.colorScheme.error),
),
],
],
);
}
UserSummary? _otherUser(Friendship friendship, String? currentUserId) { UserSummary? _otherUser(Friendship friendship, String? currentUserId) {
final selfId = currentUserId ?? ''; final selfId = currentUserId ?? '';
if (friendship.requester?.userId == selfId) return friendship.addressee; if (friendship.requester?.userId == selfId) return friendship.addressee;

View File

@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -18,6 +20,18 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController; late final TextEditingController _endpointController;
bool _saving = false; bool _saving = false;
static const List<Color> _accentPalette = [
Colors.red,
Colors.pink,
Colors.orange,
Colors.amber,
Colors.green,
Colors.teal,
Colors.blue,
Colors.indigo,
Colors.purple,
Colors.cyan,
];
@override @override
void initState() { void initState() {
@@ -130,7 +144,12 @@ class _SettingsPageState extends State<SettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>(); final endpointService = context.watch<EndpointService>();
final distanceUnitService = context.watch<DistanceUnitService>(); final distanceUnitService = context.watch<DistanceUnitService>();
if (!endpointService.isLoaded || !distanceUnitService.isLoaded) { final accentService = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
if (!endpointService.isLoaded ||
!distanceUnitService.isLoaded ||
!accentService.isLoaded ||
!themeModeService.isLoaded) {
return const Scaffold( return const Scaffold(
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
@@ -184,6 +203,73 @@ class _SettingsPageState extends State<SettingsPage> {
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text(
'Accent colour',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose your preferred accent colour or use system colours.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed:
accentService.useSystem ? null : () => accentService.setUseSystem(true),
icon: const Icon(Icons.phone_android),
label: const Text('Use system colours'),
),
..._accentPalette.map(
(color) => _AccentSwatchButton(
color: color,
selected:
!accentService.useSystem &&
accentService.seedColor.toARGB32() == color.toARGB32(),
onTap: () => accentService.setSeedColor(color),
),
),
],
),
const SizedBox(height: 24),
Text(
'Theme mode',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.settings_suggest),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeModeService.mode},
onSelectionChanged: (selection) {
final mode = selection.first;
themeModeService.setMode(mode);
},
),
const SizedBox(height: 24),
Text( Text(
'API endpoint', 'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -241,3 +327,54 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
} }
class _AccentSwatchButton extends StatelessWidget {
const _AccentSwatchButton({
required this.color,
required this.selected,
required this.onTap,
});
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final borderColor = selected
? Theme.of(context).colorScheme.onSurface
: Colors.black26;
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: selected ? 3 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: selected
? const Center(
child: Icon(
Icons.check,
size: 18,
color: Colors.white,
),
)
: null,
),
);
}
}

View File

@@ -141,6 +141,7 @@ class _StatsPageState extends State<StatsPage> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildTypeCountsSection(context, year),
_buildSection<StatsClassMileage>( _buildSection<StatsClassMileage>(
context, context,
title: 'Top classes', title: 'Top classes',
@@ -169,6 +170,21 @@ class _StatsPageState extends State<StatsPage> {
), ),
), ),
), ),
if (year.topCountries.isNotEmpty)
_buildSection<StatsCountryMileage>(
context,
title: 'Top countries',
items: year.topCountries,
emptyLabel: 'No country data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.country),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsStationVisits>( _buildSection<StatsStationVisits>(
context, context,
title: 'Top stations', title: 'Top stations',
@@ -201,6 +217,83 @@ class _StatsPageState extends State<StatsPage> {
); );
} }
List<MapEntry<String, int>> _sortedTypeCounts(Map<String, int> counts) {
final entries = counts.entries.toList();
entries.sort((a, b) {
final countCompare = b.value.compareTo(a.value);
if (countCompare != 0) return countCompare;
return a.key.compareTo(b.key);
});
return entries;
}
Widget _buildTypeCountsSection(BuildContext context, StatsYear year) {
if (year.winnerTypeCounts.isEmpty && year.totalTypeCounts.isEmpty) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Type counts',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
_buildTypeCountsRow(
context,
label: 'Winners',
counts: year.winnerTypeCounts,
),
const SizedBox(height: 8),
_buildTypeCountsRow(
context,
label: 'Total',
counts: year.totalTypeCounts,
),
],
),
);
}
Widget _buildTypeCountsRow(
BuildContext context, {
required String label,
required Map<String, int> counts,
}) {
final theme = Theme.of(context);
final entries = _sortedTypeCounts(counts);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 4),
if (entries.isEmpty)
Text(
'No data',
style: theme.textTheme.bodySmall,
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: entries
.map((entry) => _buildInfoChip(
context,
label: entry.key,
value: _countFormat.format(entry.value),
))
.toList(),
),
],
);
}
Widget _buildSection<T>( Widget _buildSection<T>(
BuildContext context, { BuildContext context, {
required String title, required String title,

View File

@@ -1,13 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -1,9 +1,12 @@
part of 'traction.dart'; part of 'traction.dart';
enum _TractionMoreAction { enum _TractionMoreAction {
exportResults,
classStats, classStats,
classLeaderboard, classLeaderboard,
adminPending, adminPending,
adminPendingChanges,
adminImport,
} }
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
@@ -12,6 +15,9 @@ class TractionPage extends StatefulWidget {
this.selectionMode = false, this.selectionMode = false,
this.selectionSingle = false, this.selectionSingle = false,
this.replacementPendingLocoId, this.replacementPendingLocoId,
this.transferFromLabel,
this.transferFromLocoId,
this.transferAllAllocations = false,
this.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
}); });
@@ -19,6 +25,9 @@ class TractionPage extends StatefulWidget {
final bool selectionMode; final bool selectionMode;
final bool selectionSingle; final bool selectionSingle;
final int? replacementPendingLocoId; final int? replacementPendingLocoId;
final String? transferFromLabel;
final int? transferFromLocoId;
final bool transferAllAllocations;
final ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -33,6 +42,13 @@ class _TractionPageState extends State<TractionPage> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId;
bool get _transferAllAllocations {
if (widget.transferAllAllocations) return true;
final param =
GoRouterState.of(context).uri.queryParameters['transferAll'];
return param?.toLowerCase() == 'true' || param == '1';
}
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
@@ -53,11 +69,14 @@ class _TractionPageState extends State<TractionPage> {
_ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global; _ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global;
final Map<String, TextEditingController> _dynamicControllers = {}; final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {}; final Map<String, dynamic> _enumSelections = {};
bool _restoredFromPrefs = false; bool _restoredFromPrefs = false;
static const int _pageSize = 100; static const int _pageSize = 100;
int _lastTractionOffset = 0; int _lastTractionOffset = 0;
String? _lastQuerySignature; String? _lastQuerySignature;
String? _transferFromLabel;
bool _isSearching = false;
bool _exporting = false;
@override @override
void initState() { void initState() {
@@ -71,6 +90,7 @@ class _TractionPageState extends State<TractionPage> {
if (!_initialised) { if (!_initialised) {
_initialised = true; _initialised = true;
_selectedKeys = {...widget.selectedKeys}; _selectedKeys = {...widget.selectedKeys};
_transferFromLabel = widget.transferFromLabel;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_initialLoad(); _initialLoad();
}); });
@@ -79,12 +99,16 @@ class _TractionPageState extends State<TractionPage> {
Future<void> _initialLoad() async { Future<void> _initialLoad() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final auth = context.read<AuthService>();
await _restoreSearchState(); await _restoreSearchState();
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) { if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
_lastTractionOffset = data.traction.length - _pageSize; _lastTractionOffset = data.traction.length - _pageSize;
} }
data.fetchClassList(); data.fetchClassList();
data.fetchEventFields(); data.fetchEventFields();
if (auth.isElevated) {
unawaited(data.fetchPendingLocoCount());
}
await _refreshTraction(); await _refreshTraction();
} }
@@ -146,11 +170,7 @@ class _TractionPageState extends State<TractionPage> {
].join(';'); ].join(';');
} }
Future<void> _refreshTraction({ Map<String, dynamic> _buildTractionFilters() {
bool append = false,
bool preservePosition = true,
}) async {
final data = context.read<DataService>();
final filters = <String, dynamic>{}; final filters = <String, dynamic>{};
final name = _nameController.text.trim(); final name = _nameController.text.trim();
if (name.isNotEmpty) filters['name'] = name; if (name.isNotEmpty) filters['name'] = name;
@@ -163,6 +183,16 @@ class _TractionPageState extends State<TractionPage> {
filters[key] = value; filters[key] = value;
} }
}); });
return filters;
}
Future<void> _refreshTraction({
bool append = false,
bool preservePosition = true,
}) async {
_setState(() => _isSearching = true);
final data = context.read<DataService>();
final filters = _buildTractionFilters();
final hadOnly = !_hasFilters; final hadOnly = !_hasFilters;
final signature = _tractionQuerySignature(filters, hadOnly); final signature = _tractionQuerySignature(filters, hadOnly);
final queryChanged = final queryChanged =
@@ -208,6 +238,7 @@ class _TractionPageState extends State<TractionPage> {
} }
await _persistSearchState(); await _persistSearchState();
_setState(() => _isSearching = false);
} }
void _clearFilters() { void _clearFilters() {
@@ -306,6 +337,7 @@ class _TractionPageState extends State<TractionPage> {
final isMobile = MediaQuery.of(context).size.width < 700; final isMobile = MediaQuery.of(context).size.width < 700;
_syncControllersForFields(data.eventFields); _syncControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields);
final transferBanner = _buildTransferBanner(data);
final slivers = <Widget>[ final slivers = <Widget>[
SliverPadding( SliverPadding(
@@ -335,6 +367,10 @@ class _TractionPageState extends State<TractionPage> {
_buildHeaderActions(context, isMobile), _buildHeaderActions(context, isMobile),
], ],
), ),
if (transferBanner != null) ...[
const SizedBox(height: 8),
transferBanner,
],
const SizedBox(height: 12), const SizedBox(height: 12),
Card( Card(
child: Padding( child: Padding(
@@ -484,8 +520,25 @@ class _TractionPageState extends State<TractionPage> {
), ),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _refreshTraction, onPressed: _refreshTraction,
icon: const Icon(Icons.search), icon: (_isSearching || data.isTractionLoading)
label: const Text('Search'), ? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context)
.colorScheme
.onPrimary,
),
),
)
: const Icon(Icons.search),
label: Text(
(_isSearching || data.isTractionLoading)
? 'Searching...'
: 'Search',
),
), ),
], ],
), ),
@@ -604,12 +657,21 @@ class _TractionPageState extends State<TractionPage> {
return content; return content;
} }
String get _currentClassLabel =>
(_selectedClass ?? _classController.text).trim();
bool get _hasClassQuery { bool get _hasClassQuery {
return (_selectedClass ?? _classController.text).trim().isNotEmpty; return _currentClassLabel.isNotEmpty;
} }
Widget _buildHeaderActions(BuildContext context, bool isMobile) { Widget _buildHeaderActions(BuildContext context, bool isMobile) {
final isElevated = context.read<AuthService>().isElevated; final isElevated = context.read<AuthService>().isElevated;
final data = context.watch<DataService>();
final pendingCount = data.pendingLocoCount;
String? pendingLabel;
if (pendingCount > 0) {
pendingLabel = pendingCount > 999 ? '999+' : pendingCount.toString();
}
final refreshButton = IconButton( final refreshButton = IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: _refreshTraction, onPressed: _refreshTraction,
@@ -637,14 +699,14 @@ class _TractionPageState extends State<TractionPage> {
); );
final hasAdminActions = isElevated; final hasAdminActions = isElevated;
final hasMoreMenu = hasClassActions || hasAdminActions;
final moreButton = !hasMoreMenu final moreButton = PopupMenuButton<_TractionMoreAction>(
? null
: PopupMenuButton<_TractionMoreAction>(
tooltip: 'More options', tooltip: 'More options',
onSelected: (action) async { onSelected: (action) async {
switch (action) { switch (action) {
case _TractionMoreAction.exportResults:
await _exportTractionResults();
break;
case _TractionMoreAction.classStats: case _TractionMoreAction.classStats:
_toggleClassStatsPanel(); _toggleClassStatsPanel();
break; break;
@@ -656,6 +718,7 @@ class _TractionPageState extends State<TractionPage> {
try { try {
await context.push('/traction/pending'); await context.push('/traction/pending');
if (!mounted) return; if (!mounted) return;
await data.fetchPendingLocoCount();
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
@@ -665,23 +728,78 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
break; break;
case _TractionMoreAction.adminPendingChanges:
final messenger = ScaffoldMessenger.of(context);
try {
await context.push('/traction/changes');
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Unable to open pending changes'),
),
);
}
break;
case _TractionMoreAction.adminImport:
await _showTractionImportSheet();
break;
} }
}, },
itemBuilder: (context) { itemBuilder: (context) {
final items = <PopupMenuEntry<_TractionMoreAction>>[]; final items = <PopupMenuEntry<_TractionMoreAction>>[];
items.add(
PopupMenuItem(
value: _TractionMoreAction.exportResults,
child: Row(
children: [
if (_exporting)
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
const Icon(Icons.download, size: 18),
const SizedBox(width: 8),
Text(_exporting ? 'Exporting...' : 'Export'),
],
),
),
);
if (hasClassActions) { if (hasClassActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.classStats, value: _TractionMoreAction.classStats,
child: Text('Class stats'), child: Row(
children: [
Icon(
_showClassStatsPanel ? Icons.check : Icons.check_box_outline_blank,
size: 18,
),
const SizedBox(width: 8),
const Text('Class stats'),
],
),
), ),
); );
} }
if (hasClassActions) { if (hasClassActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.classLeaderboard, value: _TractionMoreAction.classLeaderboard,
child: Text('Class leaderboard'), child: Row(
children: [
Icon(
_showClassLeaderboardPanel
? Icons.check
: Icons.check_box_outline_blank,
size: 18,
),
const SizedBox(width: 8),
const Text('Class leaderboard'),
],
),
), ),
); );
} }
@@ -690,9 +808,28 @@ class _TractionPageState extends State<TractionPage> {
} }
if (hasAdminActions) { if (hasAdminActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.adminPending, value: _TractionMoreAction.adminPending,
child: Text('Pending locos'), child: Row(
children: [
const Text('Pending locos'),
const Spacer(),
if (pendingLabel != null)
_countChip(context, pendingLabel),
],
),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPendingChanges,
child: Text('Pending changes'),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminImport,
child: Text('Import traction'),
), ),
); );
} }
@@ -702,7 +839,16 @@ class _TractionPageState extends State<TractionPage> {
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: () {}, onPressed: () {},
icon: const Icon(Icons.more_horiz), icon: const Icon(Icons.more_horiz),
label: const Text('More'), label: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('More'),
if (pendingLabel != null) ...[
const SizedBox(width: 6),
_countChip(context, pendingLabel),
],
],
),
), ),
), ),
); );
@@ -710,11 +856,11 @@ class _TractionPageState extends State<TractionPage> {
final desktopActions = [ final desktopActions = [
refreshButton, refreshButton,
newTractionButton, newTractionButton,
if (moreButton != null) moreButton, moreButton,
]; ];
final mobileActions = [ final mobileActions = [
if (moreButton != null) moreButton, moreButton,
newTractionButton, newTractionButton,
refreshButton, refreshButton,
]; ];
@@ -742,6 +888,345 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
Future<void> _exportTractionResults() async {
if (_exporting) return;
setState(() => _exporting = true);
final messenger = ScaffoldMessenger.of(context);
try {
final data = context.read<DataService>();
final filters = _buildTractionFilters();
final hadOnly = !_hasFilters;
final limit = data.traction.length;
final params = StringBuffer('?limit=$limit&offset=0');
if (hadOnly) params.write('&had_only=true');
if (!_mileageFirst) params.write('&mileage_first=false');
final payload = <String, dynamic>{};
final classLabel = (_selectedClass ?? _classController.text).trim();
if (classLabel.isNotEmpty) payload['class'] = classLabel;
final numberLabel = _numberController.text.trim();
if (numberLabel.isNotEmpty) payload['number'] = numberLabel;
filters.forEach((key, value) {
if (value == null) return;
if (value is String && value.trim().isEmpty) return;
payload[key] = value;
});
final response = await data.api.postBytes(
'/locos/search/v2/export${params.toString()}',
payload.isEmpty ? null : payload,
headers: const {'accept': '*/*'},
);
final filename = response.filename ?? 'traction-export.xlsx';
final saveResult = await saveBytes(
Uint8List.fromList(response.bytes),
filename,
mimeType: response.contentType,
);
if (!mounted) return;
if (saveResult.canceled) {
messenger.showSnackBar(
const SnackBar(content: Text('Export canceled.')),
);
} else {
final path = saveResult.path;
messenger.showSnackBar(
SnackBar(
content: Text(
path == null || path.isEmpty
? 'Export started.'
: 'Export saved to $path',
),
),
);
}
} on ApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: ${e.message}')),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
Future<void> _showTractionImportSheet() async {
final isElevated = context.read<AuthService>().isElevated;
if (!isElevated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Import is available to admins only.')),
);
return;
}
XFile? selectedFile;
bool uploading = false;
Map<String, dynamic>? importResult;
String? statusMessage;
String? errorMessage;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return StatefulBuilder(
builder: (context, setModalState) {
Future<void> pickFile() async {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'Spreadsheets',
extensions: ['xlsx', 'xls', 'ods', 'odf'],
),
],
);
if (file == null) return;
setModalState(() {
selectedFile = file;
importResult = null;
statusMessage = null;
errorMessage = null;
});
}
Future<void> uploadFile() async {
final file = selectedFile;
if (file == null || uploading) return;
setModalState(() {
uploading = true;
importResult = null;
statusMessage = null;
errorMessage = null;
});
try {
final data = context.read<DataService>();
final bytes = await file.readAsBytes();
final response = await data.api.postMultipartFile(
'/loco/class/import',
bytes: bytes,
filename: file.name,
headers: const {'accept': 'application/json'},
);
if (!mounted) return;
final parsed = response is Map
? Map<String, dynamic>.from(response)
: null;
if (parsed != null) {
final imported = _importCount(parsed['imported']);
final updated = _importCount(parsed['updated']);
final errors = _importErrors(parsed);
final errorNote =
errors.isNotEmpty ? ' (${errors.length} error(s))' : '';
statusMessage =
'Import complete. Imported $imported, updated $updated$errorNote.';
importResult = parsed;
} else {
statusMessage = 'Import complete.';
}
await data.fetchClassList();
await _refreshTraction(preservePosition: true);
} on ApiException catch (e) {
if (!mounted) return;
errorMessage = e.message;
} catch (e) {
if (!mounted) return;
errorMessage = e.toString();
} finally {
if (mounted) {
setModalState(() => uploading = false);
}
}
}
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Import traction data',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Upload a spreadsheet to import traction records.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
selectedFile?.name ?? 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: uploading ? null : pickFile,
icon: const Icon(Icons.upload_file),
label: const Text('Choose file'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed:
selectedFile == null || uploading ? null : uploadFile,
icon: uploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.file_upload),
label: Text(
uploading ? 'Importing...' : 'Upload and import',
),
),
if (statusMessage != null) ...[
const SizedBox(height: 12),
Text(
statusMessage!,
style: theme.textTheme.bodyMedium,
),
],
if (errorMessage != null) ...[
const SizedBox(height: 12),
Text(
errorMessage!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (importResult != null)
_buildImportSummary(context, importResult!),
],
),
),
),
);
},
);
},
);
}
int _importCount(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
List<dynamic> _importErrors(Map<String, dynamic> result) {
final errors = result['errors'];
if (errors == null) return const [];
if (errors is List) return errors;
return [errors];
}
String _stringifyImportError(dynamic err) {
if (err == null) return '';
if (err is String) return err;
try {
return jsonEncode(err);
} catch (_) {
return err.toString();
}
}
Widget _buildImportSummary(
BuildContext context,
Map<String, dynamic> result,
) {
final imported = _importCount(result['imported']);
final updated = _importCount(result['updated']);
final errors = _importErrors(result);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Import summary',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
_buildImportChip(context, 'Imported', imported.toString()),
_buildImportChip(context, 'Updated', updated.toString()),
_buildImportChip(context, 'Errors', errors.length.toString()),
],
),
if (errors.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Errors',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: errors
.map(
(err) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(_stringifyImportError(err)),
),
)
.toList(),
),
],
],
);
}
Widget _buildImportChip(
BuildContext context,
String label,
String value,
) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
);
}
Future<void> _toggleClassStatsPanel() async { Future<void> _toggleClassStatsPanel() async {
if (!_hasClassQuery) return; if (!_hasClassQuery) return;
final targetState = !_showClassStatsPanel; final targetState = !_showClassStatsPanel;
@@ -1200,6 +1685,94 @@ class _TractionPageState extends State<TractionPage> {
} }
} }
Future<void> _confirmTransfer(LocoSummary target) async {
final fromId = _transferFromLocoId;
if (fromId == null) return;
final navContext = context;
final messenger = ScaffoldMessenger.of(navContext);
final data = navContext.read<DataService>();
final fromLoco = data.traction.firstWhere(
(l) => l.id == fromId,
orElse: () => target,
);
final fromLabel = '${fromLoco.locoClass} ${fromLoco.number}'.trim();
final toLabel = '${target.locoClass} ${target.number}'.trim();
final confirmed = await showDialog<bool>(
context: navContext,
builder: (dialogContext) {
return AlertDialog(
title: Text(
_transferAllAllocations
? 'Transfer all allocations?'
: 'Transfer allocations?',
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_transferAllAllocations
? 'Transfer all user allocations from $fromLabel to $toLabel?'
: 'Transfer all allocations from $fromLabel to $toLabel?',
),
const SizedBox(height: 12),
Text(
'From: $fromLabel',
style: Theme.of(dialogContext)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w700),
),
Text('To: $toLabel'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Transfer'),
),
],
);
},
);
if (confirmed != true) return;
if (!navContext.mounted) return;
try {
final data = navContext.read<DataService>();
if (_transferAllAllocations) {
await data.transferAllAllocations(
fromLocoId: fromId,
toLocoId: target.id,
);
} else {
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
}
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
_transferAllAllocations
? 'All allocations transferred'
: 'Allocations transferred',
),
),
);
}
await _refreshTraction(preservePosition: true);
if (navContext.mounted) navContext.pop();
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to transfer allocations: $e')),
);
}
}
}
bool _isSelected(LocoSummary loco) { bool _isSelected(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}'; final keyVal = '${loco.locoClass}-${loco.number}';
return _selectedKeys.contains(keyVal); return _selectedKeys.contains(keyVal);
@@ -1223,19 +1796,115 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
Widget? _buildTransferBanner(DataService data) {
final fromId = _transferFromLocoId;
if (fromId == null) return null;
if (_transferFromLabel == null || _transferFromLabel!.trim().isEmpty) {
final from = data.traction.firstWhere(
(loco) => loco.id == fromId,
orElse: () => LocoSummary(
locoId: fromId,
locoType: '',
locoNumber: '',
locoName: '',
locoClass: '',
locoOperator: '',
),
);
final fallbackLabel = '${from.locoClass} ${from.number}'.trim().isEmpty
? 'Loco $fromId'
: '${from.locoClass} ${from.number}'.trim();
_transferFromLabel = fallbackLabel;
}
final label = _transferFromLabel ?? 'Loco $fromId';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.swap_horiz, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
_transferAllAllocations
? 'Transferring all allocations from $label. Select a loco to transfer to.'
: 'Transferring allocations from $label. Select a loco to transfer to.',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Widget _countChip(BuildContext context, String label) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
color: scheme.onPrimary,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
Widget _buildFilterInput( Widget _buildFilterInput(
BuildContext context, BuildContext context,
EventField field, EventField field,
bool isMobile, bool isMobile,
) { ) {
final width = isMobile ? double.infinity : 220.0; final width = isMobile ? double.infinity : 220.0;
final type = field.type?.toLowerCase() ?? '';
final isBooleanField =
type == 'bool' || type == 'boolean' || type.contains('bool');
if (isBooleanField) {
final currentValue = _enumSelections[field.name];
final safeValue = currentValue is bool ? currentValue : null;
return SizedBox(
width: width,
child: DropdownButtonFormField<bool?>(
value: safeValue,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: null, child: Text('Any')),
DropdownMenuItem(value: true, child: Text('Yes')),
DropdownMenuItem(value: false, child: Text('No')),
],
onChanged: (val) {
setState(() {
_enumSelections[field.name] = val;
});
_refreshTraction();
},
),
);
}
if (field.enumValues != null && field.enumValues!.isNotEmpty) { if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues! final options = field.enumValues!
.map((e) => e.toString()) .map((e) => e.toString())
.toSet() .toSet()
.toList(); .toList();
final currentValue = _enumSelections[field.name]; final currentValue = _enumSelections[field.name];
final safeValue = options.contains(currentValue) ? currentValue : null; final safeValue =
currentValue is String && options.contains(currentValue)
? currentValue
: null;
return SizedBox( return SizedBox(
width: width, width: width,
child: DropdownButtonFormField<String?>( child: DropdownButtonFormField<String?>(
@@ -1265,7 +1934,6 @@ class _TractionPageState extends State<TractionPage> {
_dynamicControllers[field.name] = controller; _dynamicControllers[field.name] = controller;
TextInputType? inputType; TextInputType? inputType;
if (field.type != null) { if (field.type != null) {
final type = field.type!.toLowerCase();
if (type.contains('int') || if (type.contains('int') ||
type.contains('num') || type.contains('num') ||
type.contains('double')) { type.contains('double')) {
@@ -1345,15 +2013,25 @@ class _TractionPageState extends State<TractionPage> {
onOpenLegs: () => _openLegs(loco), onOpenLegs: () => _openLegs(loco),
onActionComplete: _refreshTraction, onActionComplete: _refreshTraction,
onToggleSelect: widget.selectionMode && onToggleSelect: widget.selectionMode &&
widget.replacementPendingLocoId == null widget.replacementPendingLocoId == null &&
(widget.transferFromLocoId == null ||
widget.transferFromLocoId != loco.id)
? () => _toggleSelection(loco) ? () => _toggleSelection(loco)
: null, : null,
onReplacePending: widget.selectionMode && onReplacePending: widget.selectionMode &&
widget.selectionSingle && widget.selectionSingle &&
widget.replacementPendingLocoId != null widget.replacementPendingLocoId != null &&
? () => _confirmReplacePending(loco) (widget.transferFromLocoId == null ||
: null, widget.transferFromLocoId != loco.id)
); ? () => _confirmReplacePending(loco)
: null,
onTransferAllocations: widget.selectionMode &&
widget.selectionSingle &&
widget.transferFromLocoId != null &&
widget.transferFromLocoId != loco.id
? () => _confirmTransfer(loco)
: null,
);
} }
return Padding( return Padding(

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:provider/provider.dart';
class TractionPendingChangesPage extends StatefulWidget {
const TractionPendingChangesPage({super.key});
@override
State<TractionPendingChangesPage> createState() =>
_TractionPendingChangesPageState();
}
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
bool _isLoading = false;
String? _error;
List<LocoSummary> _locos = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/event/pending/locos');
if (json is List) {
setState(() {
_locos = json
.whereType<Map>()
.map((e) => LocoSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
});
} else {
setState(() {
_error = 'Unexpected response';
_locos = const [];
});
}
} catch (e) {
setState(() {
_error = e.toString();
_locos = const [];
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending changes'),
),
body: isElevated
? RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
)
: ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('Admin access required.'),
],
),
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading && _locos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Failed to load pending changes: $_error',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
if (_locos.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('No pending changes found.'),
],
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _locos.length,
itemBuilder: (context, index) {
final loco = _locos[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: TractionCard(
loco: loco,
selectionMode: false,
isSelected: false,
onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: _load,
),
onOpenTimeline: () => context.push(
'/traction/${loco.id}/timeline',
extra: {
'label': '${loco.locoClass} ${loco.number}'.trim(),
'showPending': true,
},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}

View File

@@ -20,6 +20,7 @@ class TractionCard extends StatelessWidget {
this.onToggleSelect, this.onToggleSelect,
this.onReplacePending, this.onReplacePending,
this.onActionComplete, this.onActionComplete,
this.onTransferAllocations,
}); });
final LocoSummary loco; final LocoSummary loco;
@@ -31,6 +32,7 @@ class TractionCard extends StatelessWidget {
final VoidCallback? onToggleSelect; final VoidCallback? onToggleSelect;
final VoidCallback? onReplacePending; final VoidCallback? onReplacePending;
final Future<void> Function()? onActionComplete; final Future<void> Function()? onActionComplete;
final VoidCallback? onTransferAllocations;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -145,23 +147,30 @@ class TractionCard extends StatelessWidget {
]; ];
// Prefer replace action when picking a replacement loco. // Prefer replace action when picking a replacement loco.
final addButton = onReplacePending != null final addButton = onTransferAllocations != null
? TextButton.icon( ? TextButton.icon(
onPressed: onReplacePending, onPressed: onTransferAllocations,
icon: const Icon(Icons.swap_horiz), icon: const Icon(Icons.swap_horiz),
label: const Text('Replace'), label: const Text('Transfer'),
) )
: (!isRejected && selectionMode && onToggleSelect != null) : onReplacePending != null
? TextButton.icon( ? TextButton.icon(
onPressed: onToggleSelect, onPressed: onReplacePending,
icon: Icon( icon: const Icon(Icons.swap_horiz),
isSelected label: const Text('Replace'),
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
) )
: null; : (!isRejected && selectionMode && onToggleSelect != null)
? TextButton.icon(
onPressed: onToggleSelect,
icon: Icon(
isSelected
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label:
Text(isSelected ? 'Remove' : 'Add to entry'),
)
: null;
if (isNarrow) { if (isNarrow) {
return Column( return Column(
@@ -551,6 +560,7 @@ Future<void> showTractionDetails(
LocoSummary loco, { LocoSummary loco, {
Future<void> Function()? onActionComplete, Future<void> Function()? onActionComplete,
}) async { }) async {
final navContext = context;
final hasMileageOrTrips = _hasMileageOrTrips(loco); final hasMileageOrTrips = _hasMileageOrTrips(loco);
final isVisibilityPending = final isVisibilityPending =
(loco.visibility ?? '').toLowerCase().trim() == 'pending'; (loco.visibility ?? '').toLowerCase().trim() == 'pending';
@@ -583,11 +593,11 @@ Future<void> showTractionDetails(
builder: (_, controller) { builder: (_, controller) {
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(), onPressed: () => Navigator.of(ctx).pop(),
@@ -620,16 +630,16 @@ Future<void> showTractionDetails(
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Expanded( Expanded(
child: ListView( child: ListView(
controller: controller, controller: controller,
children: [ children: [
if (isRejected && rejectedReason.isNotEmpty) if (isRejected && rejectedReason.isNotEmpty)
...[ ...[
_detailRow( _detailRow(
context, context,
'Rejection reason', 'Rejection reason',
rejectedReason, rejectedReason,
), ),
const Divider(), const Divider(),
@@ -713,57 +723,127 @@ Future<void> showTractionDetails(
); );
}, },
), ),
if (auth.isElevated || canDeleteAsOwner) ...[ const SizedBox(height: 16),
const SizedBox(height: 16), if (hasMileageOrTrips)
FilledButton.tonal( FilledButton.icon(
style: FilledButton.styleFrom( onPressed: () {
backgroundColor: Navigator.of(ctx).pop();
Theme.of(context).colorScheme.errorContainer, final transferLabel =
foregroundColor: '${loco.locoClass} ${loco.number}'.trim();
Theme.of(context).colorScheme.onErrorContainer, navContext.push(
), Uri(
onPressed: () async { path: '/traction',
final confirmed = await showDialog<bool>( queryParameters: {
context: context, 'selection': 'single',
builder: (context) { 'transferFromLocoId': loco.id.toString(),
return AlertDialog( 'transferFromLabel': transferLabel,
title: const Text('Delete loco?'), 'transferAll': '0',
content: const Text(
'This will permanently delete this loco. Are you sure?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
);
}, },
); ).toString(),
if (confirmed != true) return; extra: {
try { 'selection': 'single',
await data.adminDeleteLoco(locoId: loco.id); 'transferFromLocoId': loco.id,
messenger.showSnackBar( 'transferFromLabel': transferLabel,
const SnackBar(content: Text('Loco deleted')), 'transferAll': false,
); },
await onActionComplete?.call(); );
if (!context.mounted) return; },
Navigator.of(ctx).pop(); icon: const Icon(Icons.swap_horiz),
} catch (e) { label: const Text('Transfer allocations'),
messenger.showSnackBar( ),
SnackBar(content: Text('Failed to delete loco: $e')), if (auth.isElevated || canDeleteAsOwner) ...[
); const SizedBox(height: 8),
} ExpansionTile(
}, tilePadding: EdgeInsets.zero,
child: const Text('Delete loco'), childrenPadding: EdgeInsets.zero,
title: Text(
'More',
style: Theme.of(context).textTheme.titleSmall,
),
children: [
if (auth.isElevated) ...[
FilledButton.tonal(
onPressed: () {
Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push(
Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
'transferAll': 'true',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': true,
},
);
},
child: const Text('Transfer all allocations'),
),
const SizedBox(height: 8),
],
FilledButton.tonal(
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.errorContainer,
foregroundColor:
Theme.of(context).colorScheme.onErrorContainer,
),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete loco?'),
content: const Text(
'This will permanently delete this loco. Are you sure?',
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true) return;
try {
await data.adminDeleteLoco(locoId: loco.id);
messenger.showSnackBar(
const SnackBar(content: Text('Loco deleted')),
);
await onActionComplete?.call();
if (!context.mounted) return;
Navigator.of(ctx).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Failed to delete loco: $e')),
);
}
},
child: const Text('Delete loco'),
),
const SizedBox(height: 8),
],
), ),
], ],
], ],

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
class AnimatedCountText extends StatefulWidget {
const AnimatedCountText({
super.key,
required this.value,
required this.formatter,
this.style,
this.duration = const Duration(milliseconds: 900),
this.curve = Curves.easeOutCubic,
this.animateFromZero = true,
});
final double value;
final String Function(double) formatter;
final TextStyle? style;
final Duration duration;
final Curve curve;
final bool animateFromZero;
@override
State<AnimatedCountText> createState() => _AnimatedCountTextState();
}
class _AnimatedCountTextState extends State<AnimatedCountText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
late CurvedAnimation _curve;
double _currentValue = 0;
@override
void initState() {
super.initState();
_currentValue = widget.animateFromZero ? 0 : widget.value;
_controller = AnimationController(vsync: this, duration: widget.duration);
_curve = CurvedAnimation(parent: _controller, curve: widget.curve);
_controller.addListener(_handleTick);
_configureAnimation(from: _currentValue, to: widget.value);
if (_currentValue != widget.value) {
_controller.forward();
}
}
@override
void didUpdateWidget(covariant AnimatedCountText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.curve != widget.curve) {
_curve = CurvedAnimation(parent: _controller, curve: widget.curve);
_configureAnimation(from: _currentValue, to: widget.value);
}
if (oldWidget.value != widget.value) {
_controller.duration = widget.duration;
_configureAnimation(from: _currentValue, to: widget.value);
_controller.forward(from: 0);
}
}
void _configureAnimation({required double from, required double to}) {
_animation = Tween<double>(begin: from, end: to).animate(_curve);
}
void _handleTick() {
setState(() => _currentValue = _animation.value);
}
@override
void dispose() {
_controller.removeListener(_handleTick);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
widget.formatter(_currentValue),
style: widget.style,
);
}
}

View File

@@ -19,6 +19,8 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
int? _legId; int? _legId;
int? _shareId; int? _shareId;
Leg? _currentLeg; Leg? _currentLeg;
LegShareData? _share;
Future<LegShareData?>? _shareFuture;
bool _loading = false; bool _loading = false;
static const int _summaryLimit = 3; static const int _summaryLimit = 3;
@@ -33,11 +35,11 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) { void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.notification != widget.notification) { if (oldWidget.notification != widget.notification) {
_parseNotification(); _parseNotification(notify: true);
} }
} }
void _parseNotification() { void _parseNotification({bool notify = false}) {
final rawBody = widget.notification.body.trim(); final rawBody = widget.notification.body.trim();
// Reset // Reset
@@ -45,11 +47,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
_legId = null; _legId = null;
_currentLeg = null; _currentLeg = null;
_changes = null; _changes = null;
_share = null;
_shareFuture = null;
final parsed = _decodeBody(rawBody); final parsed = _decodeBody(rawBody);
if (parsed != null) { if (parsed != null) {
_shareId = _parseInt(parsed['share_id']); _shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']); _legId = _parseInt(parsed['leg_id']);
_currentLeg = _findCurrentLeg(_legId);
final accepted = _asStringKeyedMap(parsed['accepted_changes']); final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) { if (accepted != null) {
_changes = accepted; _changes = accepted;
@@ -58,6 +63,10 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
// Fallback: extract share_id from raw string if still missing. // Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody); _shareId ??= _extractShareId(rawBody);
_prepareShareFuture();
if (notify) {
setState(() {});
}
} }
int? _parseInt(dynamic value) { int? _parseInt(dynamic value) {
@@ -164,6 +173,25 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
} }
} }
void _prepareShareFuture() {
if (_shareId == null) return;
_shareFuture = context.read<DataService>().fetchLegShare(_shareId!.toString());
}
Future<void> _loadShareIfNeeded() async {
if (_share != null) return;
if (_shareId == null) return;
try {
final future = _shareFuture ??
context.read<DataService>().fetchLegShare(_shareId!.toString());
final share = await future;
if (!mounted) return;
_share = share;
} catch (e) {
// ignore: avoid_empty_catches
}
}
Leg? _findCurrentLeg(int? legId) { Leg? _findCurrentLeg(int? legId) {
if (legId == null) return null; if (legId == null) return null;
final data = context.read<DataService>(); final data = context.read<DataService>();
@@ -174,6 +202,79 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
} }
} }
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year.toString().padLeft(4, '0')}-'
'${dateTime.month.toString().padLeft(2, '0')}-'
'${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:'
'${dateTime.minute.toString().padLeft(2, '0')}';
}
Widget _buildLegSummary(BuildContext context) {
final future = _shareFuture;
if (future == null) {
final leg = _currentLeg;
return leg == null ? const SizedBox.shrink() : _legSummaryRow(context, leg);
}
return FutureBuilder<LegShareData?>(
future: future,
builder: (context, snapshot) {
final share = snapshot.data;
if (share != null) {
_share = share;
}
final leg = share?.entry ?? _currentLeg;
if (leg == null) {
return const SizedBox.shrink();
}
return _legSummaryRow(context, leg);
},
);
}
Widget _legSummaryRow(BuildContext context, Leg leg) {
final start = leg.route.isNotEmpty ? leg.route.first : leg.start;
final end = leg.route.isNotEmpty ? leg.route.last : leg.end;
return Text('${_formatDateTime(leg.beginTime)}$start$end');
}
String _asString(dynamic value) {
if (value == null) return '';
return value.toString();
}
Loco? _resolveLocoById(int locoId, {Leg? shareLeg}) {
for (final loco in shareLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in _currentLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in context.read<DataService>().traction) {
if (loco.id == locoId) return loco;
}
return null;
}
String _locoDisplayName(Map<String, dynamic> loco, {Leg? shareLeg}) {
final locoId = _parseInt(loco['loco_id']);
var locoClass = _asString(loco['class'] ?? loco['loco_class']);
var number = _asString(loco['number'] ?? loco['loco_number']);
if ((locoClass.isEmpty || number.isEmpty) && locoId != null) {
final resolved = _resolveLocoById(locoId, shareLeg: shareLeg);
if (resolved != null) {
if (locoClass.isEmpty) locoClass = resolved.locoClass;
if (number.isEmpty) number = resolved.number;
}
}
final parts = <String>[];
if (locoClass.isNotEmpty) parts.add(locoClass);
if (number.isNotEmpty) parts.add(number);
if (parts.isNotEmpty) return parts.join(' ');
return 'Loco ${locoId ?? '?'}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -184,10 +285,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
final entries = changes.entries.toList(); final entries = changes.entries.toList();
final shown = entries.take(_summaryLimit).toList(); final shown = entries.take(_summaryLimit).toList();
final remaining = entries.length - shown.length; final remaining = entries.length - shown.length;
final legSummary = _buildLegSummary(context);
final hasSummary = _shareFuture != null || _currentLeg != null;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (hasSummary) legSummary,
if (hasSummary) const SizedBox(height: 8),
...shown.map((e) => _changePreview(context, e)), ...shown.map((e) => _changePreview(context, e)),
if (remaining > 0) if (remaining > 0)
Padding( Padding(
@@ -325,6 +430,7 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
Future<void> _openDrawer(Map<String, dynamic> changes) async { Future<void> _openDrawer(Map<String, dynamic> changes) async {
setState(() => _loading = true); setState(() => _loading = true);
await _loadLegIdIfNeeded(); await _loadLegIdIfNeeded();
await _loadShareIfNeeded();
_currentLeg ??= _findCurrentLeg(_legId); _currentLeg ??= _findCurrentLeg(_legId);
if (!mounted) return; if (!mounted) return;
setState(() => _loading = false); setState(() => _loading = false);
@@ -398,7 +504,9 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true) (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: Theme.of(context).colorScheme.surfaceContainerHighest, : Theme.of(context).colorScheme.surfaceContainerHighest,
label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'), label: Text(
'${_locoDisplayName(loco, shareLeg: _share?.entry)} (pos ${loco['alloc_pos'] ?? '?'})',
),
avatar: Icon( avatar: Icon(
Icons.train, Icons.train,
size: 16, size: 16,

View File

@@ -7,15 +7,23 @@ import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LegShareNotificationCard extends StatelessWidget { class LegShareNotificationCard extends StatefulWidget {
const LegShareNotificationCard({super.key, required this.notification}); const LegShareNotificationCard({super.key, required this.notification});
final UserNotification notification; final UserNotification notification;
@override
State<LegShareNotificationCard> createState() => _LegShareNotificationCardState();
}
class _LegShareNotificationCardState extends State<LegShareNotificationCard> {
bool _accepting = false;
bool _rejecting = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.read<DataService>(); final data = context.read<DataService>();
final legShareId = _extractLegShareId(notification.body); final legShareId = _extractLegShareId(widget.notification.body);
if (legShareId == null) { if (legShareId == null) {
return const Text('Invalid leg share notification.'); return const Text('Invalid leg share notification.');
} }
@@ -78,16 +86,28 @@ class LegShareNotificationCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton( ElevatedButton(
onPressed: () => _accept(context, share), onPressed: _accepting ? null : () => _accept(context, share),
child: const Text('Accept'), child: _accepting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Accept'),
), ),
OutlinedButton( OutlinedButton(
onPressed: () => _inspect(context, share), onPressed: () => _inspect(context, share),
child: const Text('Inspect'), child: const Text('Inspect'),
), ),
TextButton( TextButton(
onPressed: () => _reject(context, share), onPressed: _rejecting ? null : () => _reject(context, share),
child: const Text('Reject'), child: _rejecting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Reject'),
), ),
], ],
), ),
@@ -98,12 +118,13 @@ class LegShareNotificationCard extends StatelessWidget {
} }
Future<void> _accept(BuildContext context, LegShareData share) async { Future<void> _accept(BuildContext context, LegShareData share) async {
setState(() => _accepting = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
try { try {
await data.acceptLegShare(share); await data.acceptLegShare(share);
if (!context.mounted) return; if (!context.mounted) return;
await data.dismissNotifications([notification.id]); await data.dismissNotifications([widget.notification.id]);
// Refresh legs in the background. // Refresh legs in the background.
unawaited(data.refreshLegs()); unawaited(data.refreshLegs());
messenger?.showSnackBar( messenger?.showSnackBar(
@@ -113,16 +134,21 @@ class LegShareNotificationCard extends StatelessWidget {
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to add shared entry: $e')), SnackBar(content: Text('Failed to add shared entry: $e')),
); );
} finally {
if (mounted) {
setState(() => _accepting = false);
}
} }
} }
Future<void> _reject(BuildContext context, LegShareData share) async { Future<void> _reject(BuildContext context, LegShareData share) async {
setState(() => _rejecting = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
try { try {
await data.rejectLegShare(share.id); await data.rejectLegShare(share.id);
if (!context.mounted) return; if (!context.mounted) return;
await data.dismissNotifications([notification.id]); await data.dismissNotifications([widget.notification.id]);
messenger?.showSnackBar( messenger?.showSnackBar(
const SnackBar(content: Text('Share rejected')), const SnackBar(content: Text('Share rejected')),
); );
@@ -130,6 +156,10 @@ class LegShareNotificationCard extends StatelessWidget {
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to reject share: $e')), SnackBar(content: Text('Failed to reject share: $e')),
); );
} finally {
if (mounted) {
setState(() => _rejecting = false);
}
} }
} }
@@ -140,7 +170,7 @@ class LegShareNotificationCard extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final target = share.copyWith(notificationId: notification.id); final target = share.copyWith(notificationId: widget.notification.id);
final ts = DateTime.now().millisecondsSinceEpoch; final ts = DateTime.now().millisecondsSinceEpoch;
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts'; final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
router.go(path, extra: target); router.go(path, extra: target);

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
class MultiSelectFilter extends StatefulWidget {
const MultiSelectFilter({
super.key,
required this.label,
required this.options,
required this.selected,
required this.onChanged,
this.onRefresh,
});
final String label;
final List<String> options;
final List<String> selected;
final ValueChanged<List<String>> onChanged;
final VoidCallback? onRefresh;
@override
State<MultiSelectFilter> createState() => _MultiSelectFilterState();
}
class _MultiSelectFilterState extends State<MultiSelectFilter> {
late List<String> _tempSelected;
String _query = '';
@override
void initState() {
super.initState();
_tempSelected = List.from(widget.selected);
}
@override
void didUpdateWidget(covariant MultiSelectFilter oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
_tempSelected = List.from(widget.selected);
}
}
void _openPicker() async {
_tempSelected = List.from(widget.selected);
_query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final filtered = widget.options
.where((opt) =>
_query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase()))
.toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select ${widget.label.toLowerCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
if (widget.onRefresh != null)
IconButton(
tooltip: 'Refresh',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh),
),
TextButton(
onPressed: () {
setModalState(() {
_tempSelected.clear();
});
Navigator.of(ctx).pop();
widget.onChanged(const []);
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
onChanged: (val) {
setModalState(() {
_query = val;
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final option = filtered[index];
final selected = _tempSelected.contains(option);
return CheckboxListTile(
value: selected,
title: Text(option),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!_tempSelected.contains(option)) {
_tempSelected.add(option);
}
} else {
_tempSelected.removeWhere((e) => e == option);
}
});
widget.onChanged(List.from(_tempSelected.toSet()));
},
);
},
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () {
widget.onChanged(List.from(_tempSelected.toSet()));
Navigator.of(ctx).pop();
},
icon: const Icon(Icons.check),
label: const Text('Apply'),
),
),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final hasSelection = widget.selected.isNotEmpty;
final display =
hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}';
return OutlinedButton.icon(
onPressed: _openPicker,
icon: const Icon(Icons.filter_alt),
label: SizedBox(
width: 180,
child: Text(
'${widget.label}: $display',
overflow: TextOverflow.ellipsis,
),
),
);
}
}

View File

@@ -517,8 +517,11 @@ class StatsAbout {
final mileageByYear = <int, double>{}; final mileageByYear = <int, double>{};
final classByYear = <int, List<StatsClassMileage>>{}; final classByYear = <int, List<StatsClassMileage>>{};
final networkByYear = <int, List<StatsNetworkMileage>>{}; final networkByYear = <int, List<StatsNetworkMileage>>{};
final countryByYear = <int, List<StatsCountryMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{}; final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{}; final winnersByYear = <int, int>{};
final winnerTypeCountsByYear = <int, Map<String, int>>{};
final totalTypeCountsByYear = <int, Map<String, int>>{};
void addYearMileage(dynamic entry) { void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) { if (entry is Map<String, dynamic>) {
@@ -575,6 +578,17 @@ class StatsAbout {
return const []; return const [];
} }
List<StatsCountryMileage> parseCountryList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsCountryMileage.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
void parseYearMap<T>( void parseYearMap<T>(
dynamic source, dynamic source,
Map<int, T> target, Map<int, T> target,
@@ -589,6 +603,16 @@ class StatsAbout {
} }
} }
Map<String, int> parseTypeCounts(dynamic value) {
if (value is Map) {
final entries = value.entries
.map((entry) => MapEntry(entry.key.toString(), _asInt(entry.value)))
.where((entry) => entry.key.isNotEmpty);
return Map<String, int>.fromEntries(entries);
}
return const {};
}
parseYearMap<List<StatsClassMileage>>( parseYearMap<List<StatsClassMileage>>(
json['top_classes'], json['top_classes'],
classByYear, classByYear,
@@ -599,6 +623,11 @@ class StatsAbout {
networkByYear, networkByYear,
parseNetworkList, parseNetworkList,
); );
parseYearMap<List<StatsCountryMileage>>(
json['top_countries'],
countryByYear,
parseCountryList,
);
parseYearMap<List<StatsStationVisits>>( parseYearMap<List<StatsStationVisits>>(
json['top_stations'], json['top_stations'],
stationByYear, stationByYear,
@@ -610,6 +639,19 @@ class StatsAbout {
if (year == null) return; if (year == null) return;
if (value is List) { if (value is List) {
winnersByYear[year] = value.length; winnersByYear[year] = value.length;
} else if (value is Map) {
final mapped = value.map(
(entryKey, entryValue) =>
MapEntry(entryKey.toString(), entryValue),
);
final winners = mapped['winners'];
if (winners is List) {
winnersByYear[year] = winners.length;
}
winnerTypeCountsByYear[year] =
parseTypeCounts(mapped['winner_type_counts']);
totalTypeCountsByYear[year] =
parseTypeCounts(mapped['total_type_counts']);
} }
}); });
} }
@@ -618,8 +660,11 @@ class StatsAbout {
...mileageByYear.keys, ...mileageByYear.keys,
...classByYear.keys, ...classByYear.keys,
...networkByYear.keys, ...networkByYear.keys,
...countryByYear.keys,
...stationByYear.keys, ...stationByYear.keys,
...winnersByYear.keys, ...winnersByYear.keys,
...winnerTypeCountsByYear.keys,
...totalTypeCountsByYear.keys,
}..removeWhere((year) => year == 0); }..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{}; final yearMap = <int, StatsYear>{};
@@ -629,8 +674,11 @@ class StatsAbout {
mileage: mileageByYear[year] ?? 0, mileage: mileageByYear[year] ?? 0,
topClasses: classByYear[year] ?? const [], topClasses: classByYear[year] ?? const [],
topNetworks: networkByYear[year] ?? const [], topNetworks: networkByYear[year] ?? const [],
topCountries: countryByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [], topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0, winnerCount: winnersByYear[year] ?? 0,
winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {},
totalTypeCounts: totalTypeCountsByYear[year] ?? const {},
); );
} }
@@ -649,16 +697,22 @@ class StatsYear {
final double mileage; final double mileage;
final List<StatsClassMileage> topClasses; final List<StatsClassMileage> topClasses;
final List<StatsNetworkMileage> topNetworks; final List<StatsNetworkMileage> topNetworks;
final List<StatsCountryMileage> topCountries;
final List<StatsStationVisits> topStations; final List<StatsStationVisits> topStations;
final int winnerCount; final int winnerCount;
final Map<String, int> winnerTypeCounts;
final Map<String, int> totalTypeCounts;
StatsYear({ StatsYear({
required this.year, required this.year,
required this.mileage, required this.mileage,
required this.topClasses, required this.topClasses,
required this.topNetworks, required this.topNetworks,
required this.topCountries,
required this.topStations, required this.topStations,
required this.winnerCount, required this.winnerCount,
required this.winnerTypeCounts,
required this.totalTypeCounts,
}); });
} }
@@ -694,6 +748,22 @@ class StatsNetworkMileage {
); );
} }
class StatsCountryMileage {
final String country;
final double mileage;
StatsCountryMileage({
required this.country,
required this.mileage,
});
factory StatsCountryMileage.fromJson(Map<String, dynamic> json) =>
StatsCountryMileage(
country: _asString(json['country'], 'Unknown'),
mileage: _asDouble(json['mileage']),
);
}
class StatsStationVisits { class StatsStationVisits {
final String station; final String station;
final int visits; final int visits;
@@ -1172,6 +1242,8 @@ class Leg {
final String start, end, network, notes, headcode, user; final String start, end, network, notes, headcode, user;
final String origin, destination; final String origin, destination;
final List<String> route; final List<String> route;
final List<NetworkMileage> networkMileage;
final List<CountryMileage> countryMileage;
final String? legShareId; final String? legShareId;
final LegShareMeta? sharedFrom; final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo; final List<LegShareMeta> sharedTo;
@@ -1198,6 +1270,8 @@ class Leg {
required this.driving, required this.driving,
required this.user, required this.user,
required this.locos, required this.locos,
this.networkMileage = const [],
this.countryMileage = const [],
this.endTime, this.endTime,
this.originTime, this.originTime,
this.destinationTime, this.destinationTime,
@@ -1267,6 +1341,14 @@ class Leg {
: _asInt(json['leg_end_delay']), : _asInt(json['leg_end_delay']),
origin: _asString(json['leg_origin']), origin: _asString(json['leg_origin']),
destination: _asString(json['leg_destination']), destination: _asString(json['leg_destination']),
networkMileage: (json['network_mileage'] as List? ?? const [])
.whereType<Map>()
.map((e) => NetworkMileage.fromJson(Map<String, dynamic>.from(e)))
.toList(),
countryMileage: (json['country_mileage'] as List? ?? const [])
.whereType<Map>()
.map((e) => CountryMileage.fromJson(Map<String, dynamic>.from(e)))
.toList(),
legShareId: _asString(json['leg_share_id']), legShareId: _asString(json['leg_share_id']),
sharedFrom: sharedFrom, sharedFrom: sharedFrom,
sharedTo: sharedTo, sharedTo: sharedTo,
@@ -1437,12 +1519,16 @@ class RouteResult {
final List<String> calculatedRoute; final List<String> calculatedRoute;
final List<double> costs; final List<double> costs;
final double distance; final double distance;
final List<NetworkMileage> networkMileage;
final List<CountryMileage> countryMileage;
RouteResult({ RouteResult({
required this.inputRoute, required this.inputRoute,
required this.calculatedRoute, required this.calculatedRoute,
required this.costs, required this.costs,
required this.distance, required this.distance,
this.networkMileage = const [],
this.countryMileage = const [],
}); });
factory RouteResult.fromJson(Map<String, dynamic> json) { factory RouteResult.fromJson(Map<String, dynamic> json) {
@@ -1451,10 +1537,50 @@ class RouteResult {
calculatedRoute: List<String>.from(json['calculated_route']), calculatedRoute: List<String>.from(json['calculated_route']),
costs: (json['costs'] as List).map((e) => (e as num).toDouble()).toList(), costs: (json['costs'] as List).map((e) => (e as num).toDouble()).toList(),
distance: (json['distance'] as num).toDouble(), distance: (json['distance'] as num).toDouble(),
networkMileage: (json['network_mileage'] as List? ?? const [])
.whereType<Map>()
.map((e) => NetworkMileage.fromJson(Map<String, dynamic>.from(e)))
.toList(),
countryMileage: (json['country_mileage'] as List? ?? const [])
.whereType<Map>()
.map((e) => CountryMileage.fromJson(Map<String, dynamic>.from(e)))
.toList(),
); );
} }
} }
class NetworkMileage {
final String network;
final double miles;
NetworkMileage({
required this.network,
required this.miles,
});
factory NetworkMileage.fromJson(Map<String, dynamic> json) =>
NetworkMileage(
network: _asString(json['network']),
miles: _asDouble(json['miles']),
);
}
class CountryMileage {
final String country;
final double miles;
CountryMileage({
required this.country,
required this.miles,
});
factory CountryMileage.fromJson(Map<String, dynamic> json) =>
CountryMileage(
country: _asString(json['country']),
miles: _asDouble(json['miles']),
);
}
class Station { class Station {
final int id; final int id;
final String name; final String name;
@@ -1779,12 +1905,18 @@ class ClassClearanceProgress {
final int completed; final int completed;
final int total; final int total;
final double percentComplete; final double percentComplete;
final int activeCompleted;
final int activeTotal;
final double activePercent;
ClassClearanceProgress({ ClassClearanceProgress({
required this.className, required this.className,
required this.completed, required this.completed,
required this.total, required this.total,
required this.percentComplete, required this.percentComplete,
required this.activeCompleted,
required this.activeTotal,
required this.activePercent,
}); });
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) { factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
@@ -1802,11 +1934,34 @@ class ClassClearanceProgress {
if (percent == 0 && total > 0) { if (percent == 0 && total > 0) {
percent = (completed / total) * 100; percent = (completed / total) * 100;
} }
final activeCompleted = _asInt(
json['active_completed'] ??
json['active_done'] ??
json['active_count'] ??
json['active_had'] ??
json['had_active'],
);
final activeTotal =
_asInt(json['active_total'] ??
json['active_required'] ??
json['active_goal'] ??
json['total_active']);
double activePercent = _asDouble(
json['active_percent'] ??
json['active_pct'] ??
json['active_completion'],
);
if (activePercent == 0 && activeTotal > 0) {
activePercent = (activeCompleted / activeTotal) * 100;
}
return ClassClearanceProgress( return ClassClearanceProgress(
className: name.isNotEmpty ? name : 'Class', className: name.isNotEmpty ? name : 'Class',
completed: completed, completed: completed,
total: total, total: total,
percentComplete: percent, percentComplete: percent,
activeCompleted: activeCompleted,
activeTotal: activeTotal,
activePercent: activePercent,
); );
} }
} }

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AccentColorService extends ChangeNotifier {
static const _prefsKeyUseSystem = 'accent_use_system';
static const _prefsKeySeed = 'accent_seed';
static const Color defaultSeed = Colors.red;
bool _useSystem = true;
Color _seedColor = defaultSeed;
bool _hasSavedSeed = false;
bool _loaded = false;
bool get useSystem => _useSystem;
Color get seedColor => _seedColor;
bool get hasSavedSeed => _hasSavedSeed;
bool get isLoaded => _loaded;
AccentColorService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
_useSystem = prefs.getBool(_prefsKeyUseSystem) ?? true;
final seedValue = prefs.getInt(_prefsKeySeed);
if (seedValue != null) {
_seedColor = Color(seedValue);
_hasSavedSeed = true;
}
_loaded = true;
notifyListeners();
}
Future<void> setUseSystem(bool value) async {
_useSystem = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
Future<void> setSeedColor(Color color) async {
_seedColor = color;
_useSystem = false;
_hasSavedSeed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefsKeySeed, color.toARGB32());
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function(); typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function(); typedef UnauthorizedHandler = Future<bool> Function();
class ApiService { class ApiService {
String _baseUrl; String _baseUrl;
@@ -36,52 +36,182 @@ class ApiService {
_client.close(); _client.close();
} }
Map<String, String> _buildHeaders(Map<String, String>? extra) { Map<String, String> _buildHeaders(
Map<String, String>? extra, {
bool includeAuth = true,
}) {
final token = _getToken?.call(); final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra}; final headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) { if (includeAuth && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
return headers; return headers;
} }
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async { Future<dynamic> get(
final response = await _client String endpoint, {
.get( Map<String, String>? headers,
Uri.parse('$baseUrl$endpoint'), bool includeAuth = true,
headers: _buildHeaders(headers), bool allowRetry = true,
) }) async {
.timeout(timeout); final response = await _sendWithRetry(
() => _client.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
return _processResponse(response); return _processResponse(response);
} }
Future<ApiBinaryResponse> getBytes(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final contentDisposition = response.headers['content-disposition'];
return ApiBinaryResponse(
bytes: response.bodyBytes,
statusCode: response.statusCode,
contentType: response.headers['content-type'],
filename: _extractFilename(contentDisposition),
);
}
final body = _decodeBody(response);
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: response.statusCode,
message: message,
body: body,
);
}
Future<dynamic> post( Future<dynamic> post(
String endpoint, String endpoint,
dynamic data, { dynamic data, {
Map<String, String>? headers, Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async { }) async {
final hasBody = data != null; final hasBody = data != null;
final response = await _client final response = await _sendWithRetry(
.post( () => _client.post(
Uri.parse('$baseUrl$endpoint'), Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), headers: _buildHeaders(
body: hasBody ? jsonEncode(data) : null, hasBody ? _jsonHeaders(headers) : headers,
) includeAuth: includeAuth,
.timeout(timeout); ),
body: hasBody ? jsonEncode(data) : null,
),
allowRetry: allowRetry,
);
return _processResponse(response); return _processResponse(response);
} }
Future<dynamic> postForm(String endpoint, Map<String, String> data) async { Future<ApiBinaryResponse> postBytes(
final response = await _client String endpoint,
.post( dynamic data, {
Uri.parse('$baseUrl$endpoint'), Map<String, String>? headers,
headers: _buildHeaders({ bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
),
allowRetry: allowRetry,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final contentDisposition = response.headers['content-disposition'];
return ApiBinaryResponse(
bytes: response.bodyBytes,
statusCode: response.statusCode,
contentType: response.headers['content-type'],
filename: _extractFilename(contentDisposition),
);
}
final body = _decodeBody(response);
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: response.statusCode,
message: message,
body: body,
);
}
Future<dynamic> postMultipartFile(
String endpoint, {
required List<int> bytes,
required String filename,
String fieldName = 'file',
Map<String, String>? fields,
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
Future<http.Response> send() async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl$endpoint'),
);
request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth));
if (fields != null && fields.isNotEmpty) {
request.fields.addAll(fields);
}
request.files.add(
http.MultipartFile.fromBytes(
fieldName,
bytes,
filename: filename,
),
);
final streamed = await _client.send(request);
return http.Response.fromStream(streamed);
}
final response = await _sendWithRetry(send, allowRetry: allowRetry);
return _processResponse(response);
}
Future<dynamic> postForm(
String endpoint,
Map<String, String> data, {
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
{
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json', 'accept': 'application/json',
}), },
body: data, // http package handles form-encoding for Map<String, String> includeAuth: includeAuth,
) ),
.timeout(timeout); body: data, // http package handles form-encoding for Map<String, String>
),
allowRetry: allowRetry,
);
return _processResponse(response); return _processResponse(response);
} }
@@ -89,28 +219,37 @@ class ApiService {
String endpoint, String endpoint,
dynamic data, { dynamic data, {
Map<String, String>? headers, Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async { }) async {
final hasBody = data != null; final hasBody = data != null;
final response = await _client final response = await _sendWithRetry(
.put( () => _client.put(
Uri.parse('$baseUrl$endpoint'), Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), headers: _buildHeaders(
body: hasBody ? jsonEncode(data) : null, hasBody ? _jsonHeaders(headers) : headers,
) includeAuth: includeAuth,
.timeout(timeout); ),
body: hasBody ? jsonEncode(data) : null,
),
allowRetry: allowRetry,
);
return _processResponse(response); return _processResponse(response);
} }
Future<dynamic> delete( Future<dynamic> delete(
String endpoint, { String endpoint, {
Map<String, String>? headers, Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async { }) async {
final response = await _client final response = await _sendWithRetry(
.delete( () => _client.delete(
Uri.parse('$baseUrl$endpoint'), Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers), headers: _buildHeaders(headers, includeAuth: includeAuth),
) ),
.timeout(timeout); allowRetry: allowRetry,
);
return _processResponse(response); return _processResponse(response);
} }
@@ -124,10 +263,6 @@ class ApiService {
return body; return body;
} }
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
final message = _extractErrorMessage(body); final message = _extractErrorMessage(body);
throw ApiException( throw ApiException(
statusCode: res.statusCode, statusCode: res.statusCode,
@@ -176,6 +311,34 @@ class ApiService {
} }
return body.toString(); return body.toString();
} }
Future<http.Response> _sendWithRetry(
Future<http.Response> Function() send, {
required bool allowRetry,
}) async {
var response = await send().timeout(timeout);
if (response.statusCode == 401 && allowRetry && _onUnauthorized != null) {
final refreshed = await _onUnauthorized!();
if (refreshed) {
response = await send().timeout(timeout);
}
}
return response;
}
String? _extractFilename(String? contentDisposition) {
if (contentDisposition == null || contentDisposition.isEmpty) return null;
final utf8Match =
RegExp(r"filename\\*=UTF-8''([^;]+)", caseSensitive: false)
.firstMatch(contentDisposition);
if (utf8Match != null) {
return Uri.decodeComponent(utf8Match.group(1) ?? '');
}
final match =
RegExp(r'filename="?([^\";]+)"?', caseSensitive: false)
.firstMatch(contentDisposition);
return match?.group(1);
}
} }
class ApiException implements Exception { class ApiException implements Exception {
@@ -192,3 +355,17 @@ class ApiException implements Exception {
@override @override
String toString() => 'API error $statusCode: $message'; String toString() => 'API error $statusCode: $message';
} }
class ApiBinaryResponse {
final List<int> bytes;
final int statusCode;
final String? contentType;
final String? filename;
ApiBinaryResponse({
required this.bytes,
required this.statusCode,
this.contentType,
this.filename,
});
}

View File

@@ -6,18 +6,20 @@ import 'package:mileograph_flutter/services/token_storage_service.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
final ApiService api; final ApiService api;
bool _restoring = false; bool _restoring = false;
String? _accessToken;
Future<bool>? _refreshFuture;
final TokenStorageService _tokenStorage = TokenStorageService(); final TokenStorageService _tokenStorage = TokenStorageService();
AuthService({required this.api}) { AuthService({required this.api}) {
api.setTokenProvider(() => token); api.setTokenProvider(() => token);
api.setUnauthorizedHandler(handleTokenExpired); api.setUnauthorizedHandler(_handleUnauthorized);
} }
AuthenticatedUserData? _user; AuthenticatedUserData? _user;
bool get isLoggedIn => _user != null; bool get isLoggedIn => _user != null;
String? get token => _user?.accessToken; String? get token => _accessToken;
String? get userId => _user?.userId; String? get userId => _user?.userId;
String? get username => _user?.username; String? get username => _user?.username;
String? get fullName => _user?.fullName; String? get fullName => _user?.fullName;
@@ -33,11 +35,13 @@ class AuthService extends ChangeNotifier {
required String fullName, required String fullName,
required String accessToken, required String accessToken,
required String email, required String email,
String? refreshToken,
String entriesVisibility = 'private', String entriesVisibility = 'private',
String mileageVisibility = 'private', String mileageVisibility = 'private',
bool isElevated = false, bool isElevated = false,
bool isDisabled = false, bool isDisabled = false,
}) { }) {
_accessToken = accessToken;
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
userId: userId, userId: userId,
username: username, username: username,
@@ -49,7 +53,7 @@ class AuthService extends ChangeNotifier {
isElevated: isElevated, isElevated: isElevated,
disabled: isDisabled, disabled: isDisabled,
); );
_persistToken(accessToken); _persistTokens(accessToken, refreshToken);
notifyListeners(); notifyListeners();
} }
@@ -64,8 +68,9 @@ class AuthService extends ChangeNotifier {
}; };
// 1. Get token // 1. Get token
final tokenResponse = await api.postForm('/token', formData); final tokenResponse = await api.postForm('/token', formData, includeAuth: false);
final accessToken = tokenResponse['access_token']; final accessToken = tokenResponse['access_token'];
final refreshToken = tokenResponse['refresh_token'];
// 2. Get user details // 2. Get user details
final userResponse = await api.get( final userResponse = await api.get(
@@ -83,6 +88,7 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: accessToken, accessToken: accessToken,
email: userResponse['email'], email: userResponse['email'],
refreshToken: refreshToken,
entriesVisibility: _parseVisibility( entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
'private', 'private',
@@ -103,33 +109,31 @@ class AuthService extends ChangeNotifier {
// read token from secure storage (with fallback) // read token from secure storage (with fallback)
final token = await _tokenStorage.getToken(); final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return; if (token == null || token.isEmpty) return;
_accessToken = token;
final userResponse = await api.get( final userResponse = await api.get(
'/users/me', '/users/me',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
); );
setLoginData( final restoredAccessToken = _accessToken ?? token;
userId: userResponse['user_id'], setLoginData(
username: userResponse['username'], userId: userResponse['user_id'],
fullName: userResponse['full_name'], username: userResponse['username'],
accessToken: token, fullName: userResponse['full_name'],
email: userResponse['email'], accessToken: restoredAccessToken,
entriesVisibility: _parseVisibility( email: userResponse['email'],
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], entriesVisibility: _parseVisibility(
'private', userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
), 'private',
mileageVisibility: _parseVisibility( ),
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'], mileageVisibility: _parseVisibility(
'private', userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
), 'private',
isElevated: _parseIsElevated(userResponse), ),
isDisabled: _parseIsDisabled(userResponse), isElevated: _parseIsElevated(userResponse),
); isDisabled: _parseIsDisabled(userResponse),
} catch (_) { );
} catch (_) {
await _clearToken(); await _clearToken();
} finally { } finally {
_restoring = false; _restoring = false;
@@ -140,12 +144,9 @@ class AuthService extends ChangeNotifier {
final token = await _tokenStorage.getToken(); final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return false; if (token == null || token.isEmpty) return false;
try { try {
_accessToken = token;
await api.get( await api.get(
'/validate', '/validate',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
); );
return true; return true;
} catch (_) { } catch (_) {
@@ -154,11 +155,15 @@ class AuthService extends ChangeNotifier {
} }
} }
Future<void> _persistToken(String token) async { Future<void> _persistTokens(String accessToken, String? refreshToken) async {
await _tokenStorage.setToken(token); await _tokenStorage.setToken(accessToken);
if (refreshToken != null && refreshToken.isNotEmpty) {
await _tokenStorage.setRefreshToken(refreshToken);
}
} }
Future<void> _clearToken() async { Future<void> _clearToken() async {
_accessToken = null;
await _tokenStorage.clearToken(); await _tokenStorage.clearToken();
} }
@@ -181,6 +186,61 @@ class AuthService extends ChangeNotifier {
await api.postForm('/register', formData); await api.postForm('/register', formData);
} }
Future<bool> _handleUnauthorized() async {
if (_refreshFuture != null) {
return _refreshFuture!;
}
_refreshFuture = _refreshTokens();
final refreshed = await _refreshFuture!;
_refreshFuture = null;
return refreshed;
}
Future<bool> _refreshTokens() async {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
await handleTokenExpired();
return false;
}
try {
final response = await api.post(
'/token/refresh',
{'refresh_token': refreshToken},
includeAuth: false,
allowRetry: false,
);
final accessToken = response['access_token'];
final newRefreshToken = response['refresh_token'];
if (accessToken is! String ||
accessToken.isEmpty ||
newRefreshToken is! String ||
newRefreshToken.isEmpty) {
await handleTokenExpired();
return false;
}
_accessToken = accessToken;
await _persistTokens(accessToken, newRefreshToken);
if (_user != null) {
_user = AuthenticatedUserData(
userId: _user!.userId,
username: _user!.username,
fullName: _user!.fullName,
accessToken: accessToken,
email: _user!.email,
entriesVisibility: _user!.entriesVisibility,
mileageVisibility: _user!.mileageVisibility,
isElevated: _user!.isElevated,
isDisabled: _user!.disabled,
);
}
notifyListeners();
return true;
} catch (_) {
await handleTokenExpired();
return false;
}
}
Future<void> handleTokenExpired() async { Future<void> handleTokenExpired() async {
_user = null; _user = null;
await _clearToken(); await _clearToken();

View File

@@ -52,12 +52,15 @@ extension DataServiceBadges on DataService {
int offset = 0, int offset = 0,
int limit = 20, int limit = 20,
bool append = false, bool append = false,
bool onlyIncomplete = false,
}) async { }) async {
_isClassClearanceProgressLoading = true; _isClassClearanceProgressLoading = true;
if (!append) _classClearanceProgress = [];
try { try {
final json = final onlyIncompleteParam =
await api.get('/badge/completion/class?limit=$limit&offset=$offset'); onlyIncomplete ? '&only_incomplete=true' : '';
final json = await api.get(
'/badge/completion/class?limit=$limit&offset=$offset$onlyIncompleteParam',
);
List<dynamic>? list; List<dynamic>? list;
if (json is List) { if (json is List) {
list = json; list = json;
@@ -80,7 +83,6 @@ extension DataServiceBadges on DataService {
_classClearanceHasMore = items.length >= limit; _classClearanceHasMore = items.length >= limit;
} catch (e) { } catch (e) {
debugPrint('Failed to fetch class clearance progress: $e'); debugPrint('Failed to fetch class clearance progress: $e');
if (!append) _classClearanceProgress = [];
_classClearanceHasMore = false; _classClearanceHasMore = false;
} finally { } finally {
_isClassClearanceProgressLoading = false; _isClassClearanceProgressLoading = false;

View File

@@ -7,6 +7,7 @@ class _LegFetchOptions {
final String? dateRangeStart; final String? dateRangeStart;
final String? dateRangeEnd; final String? dateRangeEnd;
final bool unallocatedOnly; final bool unallocatedOnly;
final List<String> networkFilter;
const _LegFetchOptions({ const _LegFetchOptions({
this.limit = 100, this.limit = 100,
@@ -15,6 +16,7 @@ class _LegFetchOptions {
this.dateRangeStart, this.dateRangeStart,
this.dateRangeEnd, this.dateRangeEnd,
this.unallocatedOnly = false, this.unallocatedOnly = false,
this.networkFilter = const [],
}); });
} }
@@ -52,6 +54,8 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
int _pendingLocoCount = 0;
int get pendingLocoCount => _pendingLocoCount;
List<LocoChange> _latestLocoChanges = []; List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges; List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false; bool _isLatestLocoChangesLoading = false;
@@ -94,12 +98,29 @@ class DataService extends ChangeNotifier {
bool _isEventFieldsLoading = false; bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading; bool get isEventFieldsLoading => _isEventFieldsLoading;
Future<void> fetchPendingLocoCount() async {
try {
final json = await api.get('/loco/pending?limit=1000&offset=0');
if (json is List) {
_pendingLocoCount = json.length;
} else {
_pendingLocoCount = 0;
}
} catch (e) {
debugPrint('Failed to fetch pending loco count: $e');
_pendingLocoCount = 0;
} finally {
_notifyAsync();
}
}
// Station Data // Station Data
final Map<String, List<Station>> _stationCache = {}; final Map<String, List<Station>> _stationCache = {};
final Map<String, Future<List<Station>>?> _stationInFlightByKey = {}; final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};
List<String> _stationNetworks = []; List<String> _stationNetworks = [];
Map<String, List<String>> _stationCountryNetworks = {}; Map<String, List<String>> _stationCountryNetworks = {};
DateTime? _stationFiltersFetchedAt; DateTime? _stationFiltersFetchedAt;
DateTime? _stationNetworksFetchedAt;
List<String> get stationNetworks => _stationNetworks; List<String> get stationNetworks => _stationNetworks;
Map<String, List<String>> get stationCountryNetworks => Map<String, List<String>> get stationCountryNetworks =>
_stationCountryNetworks; _stationCountryNetworks;
@@ -373,9 +394,14 @@ class DataService extends ChangeNotifier {
String? dateRangeEnd, String? dateRangeEnd,
bool append = false, bool append = false,
bool unallocatedOnly = false, bool unallocatedOnly = false,
List<String> networkFilter = const [],
}) async { }) async {
_isLegsLoading = true; _isLegsLoading = true;
if (!append) { if (!append) {
final normalizedNetworks = networkFilter
.map((network) => network.trim())
.where((network) => network.isNotEmpty)
.toList();
_lastLegsFetch = _LegFetchOptions( _lastLegsFetch = _LegFetchOptions(
limit: limit, limit: limit,
sortBy: sortBy, sortBy: sortBy,
@@ -383,6 +409,7 @@ class DataService extends ChangeNotifier {
dateRangeStart: dateRangeStart, dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd, dateRangeEnd: dateRangeEnd,
unallocatedOnly: unallocatedOnly, unallocatedOnly: unallocatedOnly,
networkFilter: normalizedNetworks,
); );
} }
final buffer = StringBuffer( final buffer = StringBuffer(
@@ -397,6 +424,13 @@ class DataService extends ChangeNotifier {
if (unallocatedOnly) { if (unallocatedOnly) {
buffer.write('&unallocated_only=true'); buffer.write('&unallocated_only=true');
} }
final networks = networkFilter
.map((network) => network.trim())
.where((network) => network.isNotEmpty)
.toList();
for (final network in networks) {
buffer.write('&network_filter=${Uri.encodeQueryComponent(network)}');
}
try { try {
final json = await api.get('/user/legs${buffer.toString()}'); final json = await api.get('/user/legs${buffer.toString()}');
@@ -426,6 +460,7 @@ class DataService extends ChangeNotifier {
dateRangeStart: _lastLegsFetch.dateRangeStart, dateRangeStart: _lastLegsFetch.dateRangeStart,
dateRangeEnd: _lastLegsFetch.dateRangeEnd, dateRangeEnd: _lastLegsFetch.dateRangeEnd,
unallocatedOnly: _lastLegsFetch.unallocatedOnly, unallocatedOnly: _lastLegsFetch.unallocatedOnly,
networkFilter: _lastLegsFetch.networkFilter,
); );
} }
@@ -651,6 +686,7 @@ class DataService extends ChangeNotifier {
_stationNetworks = []; _stationNetworks = [];
_stationCountryNetworks = {}; _stationCountryNetworks = {};
_stationFiltersFetchedAt = null; _stationFiltersFetchedAt = null;
_stationNetworksFetchedAt = null;
_notifications = []; _notifications = [];
_isNotificationsLoading = false; _isNotificationsLoading = false;
_userEntriesVisibility = 'private'; _userEntriesVisibility = 'private';
@@ -718,6 +754,9 @@ class DataService extends ChangeNotifier {
final networks = (map['networks'] as List? ?? const []) final networks = (map['networks'] as List? ?? const [])
.whereType<String>() .whereType<String>()
.toList(); .toList();
networks.sort(
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
final countryNetworksRaw = final countryNetworksRaw =
map['country_networks'] as Map? ?? const <String, dynamic>{}; map['country_networks'] as Map? ?? const <String, dynamic>{};
final countryNetworks = <String, List<String>>{}; final countryNetworks = <String, List<String>>{};
@@ -735,6 +774,31 @@ class DataService extends ChangeNotifier {
} }
} }
Future<void> fetchStationNetworks() async {
final now = DateTime.now();
final recent = _stationNetworks.isNotEmpty &&
((_stationNetworksFetchedAt != null &&
now.difference(_stationNetworksFetchedAt!) <
const Duration(minutes: 30)) ||
(_stationFiltersFetchedAt != null &&
now.difference(_stationFiltersFetchedAt!) <
const Duration(minutes: 30)));
if (recent) return;
try {
final response = await api.get('/stations/networks');
if (response is List) {
final networks = response.whereType<String>().toList();
networks.sort(
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
_stationNetworks = networks;
_stationNetworksFetchedAt = now;
}
} catch (e) {
debugPrint('Failed to fetch station networks: $e');
}
}
String _stationKey(List<String> countries, List<String> networks) { String _stationKey(List<String> countries, List<String> networks) {
final c = countries..sort(); final c = countries..sort();
final n = networks..sort(); final n = networks..sort();

View File

@@ -151,6 +151,8 @@ extension DataServiceFriendships on DataService {
overrideAddressee: targetUser, overrideAddressee: targetUser,
); );
_pendingOutgoing = [friendship, ..._pendingOutgoing]; _pendingOutgoing = [friendship, ..._pendingOutgoing];
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
return friendship; return friendship;
} }
@@ -159,6 +161,8 @@ extension DataServiceFriendships on DataService {
final json = await api.post('/friendships/$friendshipId/accept', {}); final json = await api.post('/friendships/$friendshipId/accept', {});
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted'); final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted');
_pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList(); _pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
return friendship; return friendship;
} }
@@ -177,6 +181,8 @@ extension DataServiceFriendships on DataService {
_parseAndRemoveFriendship(json, friendshipId, status: 'none'); _parseAndRemoveFriendship(json, friendshipId, status: 'none');
_pendingOutgoing = _pendingOutgoing =
_pendingOutgoing.where((f) => f.id != friendshipId).toList(); _pendingOutgoing.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
return friendship; return friendship;
} }
@@ -193,6 +199,8 @@ extension DataServiceFriendships on DataService {
_pendingIncoming.where((f) => f.id != friendshipId).toList(); _pendingIncoming.where((f) => f.id != friendshipId).toList();
_pendingOutgoing = _pendingOutgoing =
_pendingOutgoing.where((f) => f.id != friendshipId).toList(); _pendingOutgoing.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
} }

View File

@@ -62,8 +62,9 @@ extension DataServiceTraction on DataService {
_tractionHasMore = false; _tractionHasMore = false;
} finally { } finally {
_isTractionLoading = false; _isTractionLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline( Future<List<LocoAttrVersion>> fetchLocoTimeline(
@@ -466,6 +467,38 @@ extension DataServiceTraction on DataService {
} }
} }
Future<void> transferAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint('Failed to transfer allocations $fromLocoId -> $toLocoId: $e');
rethrow;
}
}
Future<void> transferAllAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer?transferAll=true', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint(
'Failed to transfer all allocations $fromLocoId -> $toLocoId: $e',
);
rethrow;
}
}
Future<void> adminDeleteLoco({required int locoId}) async { Future<void> adminDeleteLoco({required int locoId}) async {
try { try {
await api.delete('/loco/admin/delete/$locoId'); await api.delete('/loco/admin/delete/$locoId');

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeModeService extends ChangeNotifier {
static const _prefsKey = 'theme_mode_preference';
ThemeMode _mode = ThemeMode.system;
bool _loaded = false;
ThemeMode get mode => _mode;
bool get isLoaded => _loaded;
ThemeModeService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved != null) {
switch (saved) {
case 'light':
_mode = ThemeMode.light;
break;
case 'dark':
_mode = ThemeMode.dark;
break;
default:
_mode = ThemeMode.system;
}
}
_loaded = true;
notifyListeners();
}
Future<void> setMode(ThemeMode mode) async {
_mode = mode;
final prefs = await SharedPreferences.getInstance();
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
_ => 'system',
};
await prefs.setString(_prefsKey, value);
notifyListeners();
}
}

View File

@@ -10,7 +10,8 @@ class TokenStorageService {
factory TokenStorageService() => _instance; factory TokenStorageService() => _instance;
static const _tokenKey = 'auth_token'; static const _accessTokenKey = 'auth_token';
static const _refreshTokenKey = 'refresh_token';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
Future<SharedPreferences> get _prefs async => Future<SharedPreferences> get _prefs async =>
@@ -18,17 +19,17 @@ class TokenStorageService {
Future<void> setToken(String token) async { Future<void> setToken(String token) async {
try { try {
await _secureStorage.write(key: _tokenKey, value: token); await _secureStorage.write(key: _accessTokenKey, value: token);
} catch (_) { } catch (_) {
// ignore secure storage failures in debug/unsupported environments // ignore secure storage failures in debug/unsupported environments
} }
final prefs = await _prefs; final prefs = await _prefs;
await prefs.setString(_tokenKey, token); await prefs.setString(_accessTokenKey, token);
} }
Future<String?> getToken() async { Future<String?> getToken() async {
try { try {
final secured = await _secureStorage.read(key: _tokenKey); final secured = await _secureStorage.read(key: _accessTokenKey);
if (secured != null && secured.isNotEmpty) { if (secured != null && secured.isNotEmpty) {
return secured; return secured;
} }
@@ -36,22 +37,48 @@ class TokenStorageService {
// ignore and fall back // ignore and fall back
} }
final prefs = await _prefs; final prefs = await _prefs;
final token = prefs.getString(_tokenKey); final token = prefs.getString(_accessTokenKey);
return (token == null || token.isEmpty) ? null : token; return (token == null || token.isEmpty) ? null : token;
} }
Future<void> clearToken() async { Future<void> clearToken() async {
try { try {
await _secureStorage.delete(key: _tokenKey); await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
} catch (_) { } catch (_) {
// ignore // ignore
} }
final prefs = await _prefs; final prefs = await _prefs;
await prefs.remove(_tokenKey); await prefs.remove(_accessTokenKey);
await prefs.remove(_refreshTokenKey);
} }
Future<bool> hasToken() async { Future<bool> hasToken() async {
final token = await getToken(); final token = await getToken();
return token != null && token.isNotEmpty; return token != null && token.isNotEmpty;
} }
Future<void> setRefreshToken(String token) async {
try {
await _secureStorage.write(key: _refreshTokenKey, value: token);
} catch (_) {
// ignore secure storage failures in debug/unsupported environments
}
final prefs = await _prefs;
await prefs.setString(_refreshTokenKey, token);
}
Future<String?> getRefreshToken() async {
try {
final secured = await _secureStorage.read(key: _refreshTokenKey);
if (secured != null && secured.isNotEmpty) {
return secured;
}
} catch (_) {
// ignore and fall back
}
final prefs = await _prefs;
final token = prefs.getString(_refreshTokenKey);
return (token == null || token.isEmpty) ? null : token;
}
} }

View File

@@ -21,14 +21,17 @@ import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart'; import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_page.dart';
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart'; import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = final GlobalKey<NavigatorState> _shellNavigatorKey =
@@ -103,12 +106,6 @@ class _MyAppState extends State<MyApp> {
late final GoRouter _router; late final GoRouter _router;
bool _routerInitialized = false; bool _routerInitialized = false;
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -188,10 +185,43 @@ class _MyAppState extends State<MyApp> {
'', '',
) )
: null; : null;
final transferFromLocoIdStr =
state.uri.queryParameters['transferFromLocoId'];
final transferFromLocoId = transferFromLocoIdStr != null
? int.tryParse(transferFromLocoIdStr)
: state.extra is Map
? int.tryParse(
(state.extra as Map)['transferFromLocoId']
?.toString() ??
'',
)
: null;
final transferFromLabel = state.uri.queryParameters['transferFromLabel'] ??
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
bool transferAllAllocations = false;
final transferAllParam =
state.uri.queryParameters['transferAll'] ??
(state.extra is Map
? (state.extra as Map)['transferAll']?.toString()
: null);
if (transferAllParam != null) {
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
transferAllParam == '1';
}
if (!transferAllAllocations && state.extra is Map) {
final raw = (state.extra as Map)['transferAll'];
if (raw is bool) {
transferAllAllocations = raw;
}
}
final selectionMode = final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) || (selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null; replacementPendingLocoId != null ||
transferFromLocoId != null;
final selectionSingle = replacementPendingLocoId != null || final selectionSingle = replacementPendingLocoId != null ||
transferFromLocoId != null ||
selectionParam?.toLowerCase() == 'single' || selectionParam?.toLowerCase() == 'single' ||
selectionParam == '1' || selectionParam == '1' ||
selectionParam?.toLowerCase() == 'true'; selectionParam?.toLowerCase() == 'true';
@@ -199,6 +229,9 @@ class _MyAppState extends State<MyApp> {
selectionMode: selectionMode, selectionMode: selectionMode,
selectionSingle: selectionSingle, selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
); );
}, },
), ),
@@ -206,6 +239,11 @@ class _MyAppState extends State<MyApp> {
path: '/traction/pending', path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(), builder: (context, state) => const TractionPendingPage(),
), ),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
@@ -223,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra; label = extra;
} }
if (label.trim().isEmpty) label = 'Loco $locoId'; if (label.trim().isEmpty) label = 'Loco $locoId';
return LocoTimelinePage(locoId: locoId, locoLabel: label); bool showPending = false;
if (extra is Map && extra['showPending'] is bool) {
showPending = extra['showPending'] as bool;
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
forceShowPending: showPending,
);
}, },
), ),
GoRoute( GoRoute(
@@ -325,20 +371,34 @@ class _MyAppState extends State<MyApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
final seedColor =
accent.hasSavedSeed ? accent.seedColor : AccentColorService.defaultSeed;
final useSystemColors = accent.useSystem;
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final colorSchemeLight = useSystemColors && lightDynamic != null
? lightDynamic
: ColorScheme.fromSeed(seedColor: seedColor);
final colorSchemeDark = useSystemColors && darkDynamic != null
? darkDynamic
: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
return MaterialApp.router( return MaterialApp.router(
title: 'Mileograph', title: 'Mileograph',
routerConfig: _router, routerConfig: _router,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: lightDynamic ?? defaultLight, colorScheme: colorSchemeLight,
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark, colorScheme: colorSchemeDark,
), ),
themeMode: ThemeMode.system, themeMode: themeModeService.mode,
); );
}, },
); );
@@ -494,26 +554,34 @@ class _MyHomePageState extends State<MyHomePage> {
) )
.toList(); .toList();
final logo = Text.rich(
TextSpan(
children: const [
TextSpan(text: "Mile"),
TextSpan(
text: "O",
style: TextStyle(color: Colors.red),
),
TextSpan(text: "graph"),
],
style: const TextStyle(
decoration: TextDecoration.none,
color: Colors.white,
fontFamily: "Tomatoes",
),
),
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text.rich( title: isWide
TextSpan( ? logo
children: const [ : FittedBox(
TextSpan(text: "Mile"), fit: BoxFit.scaleDown,
TextSpan( alignment: Alignment.centerLeft,
text: "O", child: logo,
style: TextStyle(color: Colors.red),
), ),
TextSpan(text: "graph"),
],
style: const TextStyle(
decoration: TextDecoration.none,
color: Colors.white,
fontFamily: "Tomatoes",
),
),
),
actions: [ actions: [
_buildNotificationAction(context, data), _buildNotificationAction(context, data),
IconButton( IconButton(

View File

@@ -0,0 +1,29 @@
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
class SaveResult {
final String? path;
final bool canceled;
const SaveResult({this.path, required this.canceled});
}
Future<SaveResult> saveBytes(
Uint8List bytes,
String filename, {
String? mimeType,
}) async {
final safeName = filename.trim().isEmpty ? 'export.bin' : filename.trim();
final location = await getSaveLocation(suggestedName: safeName);
if (location == null) {
return const SaveResult(canceled: true);
}
final file = XFile.fromData(
bytes,
mimeType: mimeType ?? 'application/octet-stream',
name: safeName,
);
await file.saveTo(location.path);
return SaveResult(path: location.path, canceled: false);
}

View File

@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "mileograph_flutter") set(BINARY_NAME "Mileograph")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.mileograph_flutter") set(APPLICATION_ID "com.petegregoryy.mileograph_flutter")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h> #include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar = g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
) )

View File

@@ -6,12 +6,14 @@ import FlutterMacOS
import Foundation import Foundation
import dynamic_color import dynamic_color
import file_selector_macos
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
url: "https://pub.dev"
source: hosted
version: "0.3.5+1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -121,6 +129,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_selector:
dependency: "direct main"
description:
name: file_selector
sha256: "5f1d15a7f17115038f433d1b0ea57513cc9e29a9d5338d166cb0bef3fa90a7a0"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: "1ce58b609289551f8ec07265476720e77d19764339cc1d8e4df3c4d34dac6499"
url: "https://pub.dev"
source: hosted
version: "0.5.1+17"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: fe9f52123af16bba4ad65bd7e03defbbb4b172a38a8e6aaa2a869a0c56a5f5fb
url: "https://pub.dev"
source: hosted
version: "0.5.3+2"
file_selector_linux:
dependency: "direct main"
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: "direct main"
description:
name: file_selector_macos
sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c"
url: "https://pub.dev"
source: hosted
version: "0.9.4+4"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_web:
dependency: "direct main"
description:
name: file_selector_web
sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7
url: "https://pub.dev"
source: hosted
version: "0.9.4+2"
file_selector_windows:
dependency: "direct main"
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -190,6 +262,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -328,6 +408,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
@@ -533,6 +621,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -591,4 +703,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.32.0"

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.7.0+8 version: 0.8.0+17
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -37,6 +37,12 @@ dependencies:
dynamic_color: ^1.6.6 dynamic_color: ^1.6.6
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
collection: ^1.18.0 collection: ^1.18.0
file_selector: ^1.0.3
file_selector_linux: ^0.9.3
file_selector_macos: ^0.9.4
file_selector_windows: ^0.9.3
file_selector_web: ^0.9.4
flutter_svg: ^2.0.10
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
@@ -68,6 +74,8 @@ flutter:
- family: Tomatoes - family: Tomatoes
fonts: fonts:
- asset: lib/assets/fonts/Tomatoes-O8L8.ttf - asset: lib/assets/fonts/Tomatoes-O8L8.ttf
assets:
- assets/logos/pg_logo_v2.svg
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg

View File

@@ -0,0 +1,265 @@
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'test_data.dart';
class FakeApiService extends ApiService {
FakeApiService({super.baseUrl = 'https://example.com'});
final Map<String, dynamic> getResponses = {};
final Map<String, dynamic> postResponses = {};
@override
Future<dynamic> get(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
return getResponses[endpoint];
}
@override
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
return postResponses[endpoint] ?? {};
}
}
class FakeAuthService extends AuthService {
FakeAuthService({
required super.api,
this.userIdValue = 'user-123',
this.usernameValue = 'railfan',
this.fullNameValue = 'Alex Rider',
this.isElevatedValue = true,
this.isLoggedInValue = true,
});
final String? userIdValue;
final String? usernameValue;
final String? fullNameValue;
final bool isElevatedValue;
final bool isLoggedInValue;
@override
bool get isLoggedIn => isLoggedInValue;
@override
String? get userId => userIdValue;
@override
String? get username => usernameValue;
@override
String? get fullName => fullNameValue;
@override
bool get isElevated => isElevatedValue;
}
class FakeDataService extends DataService {
FakeDataService({ApiService? api})
: super(api: api ?? FakeApiService());
HomepageStats? homepageStatsValue;
bool isHomepageLoadingValue = false;
List<TripSummary> tripsValue = [];
List<Leg> onThisDayValue = [];
bool isOnThisDayLoadingValue = false;
List<ClassClearanceProgress> classClearanceProgressValue = [];
bool isClassClearanceProgressLoadingValue = false;
List<LocoSummary> tractionValue = [];
bool isTractionLoadingValue = false;
bool tractionHasMoreValue = false;
List<LocoChange> latestLocoChangesValue = [];
bool isLatestLocoChangesLoadingValue = false;
bool latestLocoChangesHasMoreValue = false;
List<Leg> legsValue = [];
bool isLegsLoadingValue = false;
bool legsHasMoreValue = false;
List<TripDetail> tripDetailsValue = [];
List<TripSummary> tripListValue = [];
bool isTripDetailsLoadingValue = false;
List<String> locoClassesValue = [];
List<EventField> eventFieldsValue = [];
bool isEventFieldsLoadingValue = false;
int pendingLocoCountValue = 0;
double currentYearMileageValue = 0;
@override
HomepageStats? get homepageStats => homepageStatsValue;
@override
bool get isHomepageLoading => isHomepageLoadingValue;
@override
List<TripSummary> get trips => tripsValue;
@override
List<Leg> get onThisDay => onThisDayValue;
@override
bool get isOnThisDayLoading => isOnThisDayLoadingValue;
@override
List<ClassClearanceProgress> get classClearanceProgress =>
classClearanceProgressValue;
@override
bool get isClassClearanceProgressLoading =>
isClassClearanceProgressLoadingValue;
@override
List<LocoSummary> get traction => tractionValue;
@override
bool get isTractionLoading => isTractionLoadingValue;
@override
bool get tractionHasMore => tractionHasMoreValue;
@override
List<LocoChange> get latestLocoChanges => latestLocoChangesValue;
@override
bool get isLatestLocoChangesLoading => isLatestLocoChangesLoadingValue;
@override
bool get latestLocoChangesHasMore => latestLocoChangesHasMoreValue;
@override
List<Leg> get legs => legsValue;
@override
bool get isLegsLoading => isLegsLoadingValue;
@override
bool get legsHasMore => legsHasMoreValue;
@override
List<TripDetail> get tripDetails => tripDetailsValue;
@override
List<TripSummary> get tripList => tripListValue;
@override
bool get isTripDetailsLoading => isTripDetailsLoadingValue;
@override
List<String> get locoClasses => locoClassesValue;
@override
List<EventField> get eventFields => eventFieldsValue;
@override
bool get isEventFieldsLoading => isEventFieldsLoadingValue;
@override
int get pendingLocoCount => pendingLocoCountValue;
@override
double getMileageForCurrentYear() => currentYearMileageValue;
@override
Future<void> fetchHomepageStats() async {}
@override
Future<void> fetchOnThisDay({DateTime? date}) async {}
Future<void> fetchTripDetails() async {}
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {}
Future<void> fetchLatestLocoChanges({
int limit = 100,
int offset = 0,
bool append = false,
}) async {}
Future<void> fetchClassClearanceProgress({
int offset = 0,
int limit = 20,
bool append = false,
bool onlyIncomplete = false,
}) async {}
@override
Future<void> fetchLegs({
int offset = 0,
int limit = 100,
String sortBy = 'date',
int sortDirection = 0,
String? dateRangeStart,
String? dateRangeEnd,
bool append = false,
bool unallocatedOnly = false,
List<String> networkFilter = const [],
}) async {}
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
return const [];
}
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 100,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
bool append = false,
Map<String, dynamic>? filters,
}) async {}
Future<List<String>> fetchClassList({bool force = false}) async {
return locoClassesValue;
}
@override
Future<void> fetchEventFields({bool force = false}) async {}
@override
Future<void> fetchPendingLocoCount() async {}
@override
Future<void> fetchStationNetworks() async {}
Future<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
return TestData.classStats;
}
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
String locoClass, {
bool friends = false,
}) async {
return TestData.classLeaderboard;
}
}
class FakeDistanceUnitService extends DistanceUnitService {
FakeDistanceUnitService({this.unitOverride});
final DistanceUnit? unitOverride;
@override
DistanceUnit get unit => unitOverride ?? super.unit;
@override
String format(
double miles, {
int decimals = 1,
bool includeUnit = true,
}) {
final formatter = DistanceFormatter(unitOverride ?? super.unit);
return formatter.format(miles, decimals: decimals, includeUnit: includeUnit);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
Widget buildTestApp({
required Widget child,
required DataService dataService,
required AuthService authService,
DistanceUnitService? distanceUnitService,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: authService),
ChangeNotifierProvider<DataService>.value(value: dataService),
ChangeNotifierProvider<DistanceUnitService>.value(
value: distanceUnitService ?? DistanceUnitService(),
),
],
child: MaterialApp(home: child),
);
}
Widget buildTestRouterApp({
required GoRouter router,
required DataService dataService,
required AuthService authService,
DistanceUnitService? distanceUnitService,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: authService),
ChangeNotifierProvider<DataService>.value(value: dataService),
ChangeNotifierProvider<DistanceUnitService>.value(
value: distanceUnitService ?? DistanceUnitService(),
),
],
child: MaterialApp.router(routerConfig: router),
);
}

328
test/helpers/test_data.dart Normal file
View File

@@ -0,0 +1,328 @@
import 'package:mileograph_flutter/objects/objects.dart';
class TestData {
static final user = UserData(
username: 'railfan',
fullName: 'Alex Rider',
userId: 'user-123',
email: 'alex@example.com',
entriesVisibility: 'public',
mileageVisibility: 'public',
elevated: true,
disabled: false,
);
static final topLocos = [
LocoSummary(
locoId: 100,
locoType: 'D',
locoNumber: '001',
locoName: 'Atlas',
locoClass: '67',
locoOperator: 'TestRail',
mileage: 1200.5,
journeys: 18,
trips: 6,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Blue',
location: 'Depot',
),
LocoSummary(
locoId: 101,
locoType: 'D',
locoNumber: '002',
locoName: 'Orion',
locoClass: '68',
locoOperator: 'TestRail',
mileage: 980.2,
journeys: 12,
trips: 4,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Orange',
location: 'Central',
),
];
static final leaderboard = [
LeaderboardEntry(
userId: 'u1',
username: 'driver_one',
userFullName: 'Driver One',
mileage: 3456.7,
),
LeaderboardEntry(
userId: 'u2',
username: 'driver_two',
userFullName: 'Driver Two',
mileage: 2999.1,
),
];
static final tripSummaries = [
TripSummary(
tripId: 1,
tripName: 'North Run',
tripMileage: 220.4,
legCount: 3,
locoStats: [
TripLocoStat(locoClass: '67', number: '001', won: true),
TripLocoStat(locoClass: '68', number: '002', won: false),
],
startDate: DateTime(2023, 10, 11),
endDate: DateTime(2023, 10, 13),
),
TripSummary(
tripId: 2,
tripName: 'Harbor Loop',
tripMileage: 85.6,
legCount: 1,
locoStats: const [],
startDate: DateTime(2023, 11, 5),
endDate: DateTime(2023, 11, 5),
),
];
static final tripDetails = [
TripDetail(
id: 1,
name: 'North Run',
mileage: 220.4,
legCount: 3,
locoStats: [
TripLocoStat(locoClass: '67', number: '001', won: true),
],
legs: [
TripLeg(
id: 10,
start: 'London',
end: 'Leeds',
beginTime: DateTime(2023, 10, 11, 9, 15),
network: 'National',
route: 'London → Leeds',
mileage: 185.2,
notes: 'Clear run',
locos: [
Loco(
id: 100,
type: 'D',
number: '001',
name: 'Atlas',
locoClass: '67',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
],
),
];
static final legs = [
Leg(
id: 501,
tripId: 1,
start: 'London',
end: 'Oxford',
beginTime: DateTime(2023, 9, 12, 7, 45),
timezone: 0,
network: 'National',
route: ['London', 'Oxford'],
mileage: 62.3,
notes: 'Morning service',
headcode: '1A00',
driving: 0,
user: 'railfan',
locos: [
Loco(
id: 100,
type: 'D',
number: '001',
name: 'Atlas',
locoClass: '67',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
Leg(
id: 502,
tripId: 2,
start: 'Oxford',
end: 'Bristol',
beginTime: DateTime(2023, 9, 13, 16, 10),
timezone: 0,
network: 'National',
route: ['Oxford', 'Bristol'],
mileage: 74.8,
notes: 'Evening run',
headcode: '1B10',
driving: 0,
user: 'railfan',
locos: [
Loco(
id: 101,
type: 'D',
number: '002',
name: 'Orion',
locoClass: '68',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
];
static List<Leg> onThisDayLegs() {
final pastYear = DateTime.now().year - 1;
return [
Leg(
id: 900,
tripId: 3,
start: 'York',
end: 'Durham',
beginTime: DateTime(pastYear, 7, 4, 10, 30),
timezone: 0,
network: 'National',
route: ['York', 'Durham'],
mileage: 60.0,
notes: 'Anniversary run',
headcode: '1C11',
driving: 0,
user: 'railfan',
locos: [
Loco(
id: 102,
type: 'D',
number: '003',
name: 'Nova',
locoClass: '66',
operator: 'TestRail',
notes: '',
evn: '',
),
],
),
];
}
static final latestLocoChanges = [
LocoChange(
locoId: 200,
locoClass: '67',
locoNumber: '001',
locoName: 'Atlas',
attrCode: 'loco_status',
attrDisplay: 'Status',
valueDisplay: 'Active',
validFrom: DateTime(2023, 10, 1),
approvedAt: DateTime(2023, 10, 2),
approvedBy: 'moderator',
),
];
static final classClearance = [
ClassClearanceProgress(
className: 'Class 67',
completed: 12,
total: 30,
percentComplete: 40.0,
activeCompleted: 8,
activeTotal: 20,
activePercent: 40.0,
),
];
static final traction = [
LocoSummary(
locoId: 100,
locoType: 'D',
locoNumber: '001',
locoName: 'Atlas',
locoClass: '67',
locoOperator: 'TestRail',
mileage: 1200.5,
journeys: 18,
trips: 6,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Blue',
location: 'Depot',
),
LocoSummary(
locoId: 101,
locoType: 'D',
locoNumber: '002',
locoName: 'Orion',
locoClass: '68',
locoOperator: 'TestRail',
mileage: 980.2,
journeys: 12,
trips: 4,
status: 'Active',
domain: 'mainline',
owner: 'TestRail',
livery: 'Orange',
location: 'Central',
),
];
static final eventFields = [
const EventField(name: 'power_unit', display: 'Power Unit', type: 'text'),
const EventField(
name: 'cab_air_conditioning',
display: 'Cab A/C',
enumValues: ['Yes', 'No'],
),
];
static final classStats = <String, dynamic>{
'loco_class': '67',
'total_mileage_with_class': 5500.5,
'avg_mileage_per_entry': 80.2,
'avg_mileage_per_loco_had': 420.0,
'had_count': 12,
'entries_with_class': 42,
'class_stats': {
'total': 30,
'status': [
{'status': 'Active', 'count': 18},
{'status': 'Stored', 'count': 12},
],
'domain': [
{'domain': 'mainline', 'count': 20},
{'domain': 'heritage', 'count': 10},
],
},
};
static final classLeaderboard = [
LeaderboardEntry(
userId: 'u3',
username: 'fan_three',
userFullName: 'Fan Three',
mileage: 1500.0,
),
];
static final homepageStats = HomepageStats(
totalMileage: 4523.8,
yearlyMileage: [
YearlyMileage(year: DateTime.now().year, mileage: 1200.5),
YearlyMileage(year: DateTime.now().year - 1, mileage: 990.2),
],
topLocos: topLocos,
leaderboard: leaderboard,
friendsLeaderboard: leaderboard,
trips: tripSummaries,
legCount: 27,
user: user,
);
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/dashboard.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Dashboard renders stats and panels', (tester) async {
final data = FakeDataService()
..homepageStatsValue = TestData.homepageStats
..tripsValue = TestData.tripSummaries
..onThisDayValue = TestData.onThisDayLegs()
..classClearanceProgressValue = TestData.classClearance
..latestLocoChangesValue = TestData.latestLocoChanges
..tractionValue = TestData.traction
..currentYearMileageValue = 1200.5;
final auth = FakeAuthService(api: FakeApiService());
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Dashboard(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget);
expect(find.textContaining('Welcome back, Alex Rider'), findsOneWidget);
expect(find.text('Top traction'), findsOneWidget);
expect(find.text('67 001'), findsOneWidget);
expect(find.text('On this day'), findsOneWidget);
expect(find.text('York → Durham'), findsOneWidget);
expect(find.text('Latest Loco Changes'), findsOneWidget);
expect(find.text('Status'), findsWidgets);
expect(find.text('Active'), findsWidgets);
});
testWidgets('Dashboard shows loading overlay without stats', (tester) async {
final data = FakeDataService()
..homepageStatsValue = null
..isHomepageLoadingValue = true;
final auth = FakeAuthService(api: FakeApiService());
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Dashboard(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pump();
expect(find.text('Loading dashboard data...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsWidgets);
});
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Entries page shows legs and mileage summary', (tester) async {
final data = FakeDataService()..legsValue = TestData.legs;
final auth = FakeAuthService(api: FakeApiService());
final distanceUnits = FakeDistanceUnitService(
unitOverride: DistanceUnit.milesDecimal,
);
await tester.pumpWidget(
buildTestApp(
child: const LegsPage(),
dataService: data,
authService: auth,
distanceUnitService: distanceUnits,
),
);
await tester.pumpAndSettle();
expect(find.text('Entries'), findsOneWidget);
expect(find.text('Page mileage'), findsOneWidget);
expect(find.textContaining('mi'), findsWidgets);
expect(find.text('London'), findsOneWidget);
expect(find.text('Oxford'), findsOneWidget);
});
testWidgets('Entries page shows empty state', (tester) async {
final data = FakeDataService()
..legsValue = []
..isLegsLoadingValue = false;
final auth = FakeAuthService(api: FakeApiService());
await tester.pumpWidget(
buildTestApp(
child: const LegsPage(),
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('No entries found'), findsOneWidget);
expect(find.text('Adjust the filters or add a new leg.'), findsOneWidget);
});
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Traction page shows list data', (tester) async {
final data = FakeDataService()
..tractionValue = TestData.traction
..locoClassesValue = ['67', '68']
..eventFieldsValue = TestData.eventFields;
final auth = FakeAuthService(api: FakeApiService(), isElevatedValue: true);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const TractionPage(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('Traction'), findsOneWidget);
expect(find.text('67'), findsWidgets);
expect(find.text('001'), findsWidgets);
});
testWidgets('Traction page shows export when class filter set', (tester) async {
final data = FakeDataService()
..tractionValue = TestData.traction
..locoClassesValue = ['67', '68']
..eventFieldsValue = TestData.eventFields;
final auth = FakeAuthService(api: FakeApiService(), isElevatedValue: true);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const TractionPage(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Export'), findsNothing);
final classField = find.byWidgetPredicate(
(widget) =>
widget is TextField && widget.decoration?.labelText == 'Class',
);
await tester.enterText(classField, '67');
await tester.pump();
expect(find.text('Export 67'), findsOneWidget);
});
testWidgets('Traction page shows admin import option', (tester) async {
final data = FakeDataService()
..tractionValue = TestData.traction
..locoClassesValue = ['67', '68']
..eventFieldsValue = TestData.eventFields
..pendingLocoCountValue = 2;
final auth = FakeAuthService(api: FakeApiService(), isElevatedValue: true);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const TractionPage(),
),
],
);
await tester.pumpWidget(
buildTestRouterApp(
router: router,
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton));
await tester.pumpAndSettle();
expect(find.text('Import traction'), findsOneWidget);
});
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import '../helpers/fake_services.dart';
import '../helpers/test_app.dart';
import '../helpers/test_data.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Trips page shows trip details', (tester) async {
final data = FakeDataService()
..tripDetailsValue = TestData.tripDetails
..tripListValue = TestData.tripSummaries;
final auth = FakeAuthService(api: FakeApiService());
final distanceUnits = FakeDistanceUnitService(
unitOverride: DistanceUnit.milesDecimal,
);
await tester.pumpWidget(
buildTestApp(
child: const TripsPage(),
dataService: data,
authService: auth,
distanceUnitService: distanceUnits,
),
);
await tester.pumpAndSettle();
expect(find.text('Trips'), findsOneWidget);
expect(find.text('North Run'), findsOneWidget);
expect(find.textContaining('mi'), findsWidgets);
});
testWidgets('Trips page shows empty state', (tester) async {
final data = FakeDataService()
..tripDetailsValue = []
..tripListValue = []
..isTripDetailsLoadingValue = false;
final auth = FakeAuthService(api: FakeApiService());
await tester.pumpWidget(
buildTestApp(
child: const TripsPage(),
dataService: data,
authService: auth,
),
);
await tester.pumpAndSettle();
expect(find.text('No trips yet'), findsOneWidget);
expect(
find.text('Use the Add entry flow to start grouping legs into trips.'),
findsOneWidget,
);
});
}

View File

@@ -7,11 +7,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
DynamicColorPluginCApiRegisterWithRegistrar( DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
} }

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
file_selector_windows
flutter_secure_storage_windows flutter_secure_storage_windows
) )