Compare commits

..

19 Commits

Author SHA1 Message Date
d8bcde1312 Add support for file uploads using new async upload jobs, add admin section for uploading distance files
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 59s
Release / web-build (push) Successful in 1m24s
Release / android-build (push) Successful in 6m13s
Release / release-master (push) Successful in 8s
Release / release-dev (push) Successful in 13s
2026-01-27 21:43:34 +00:00
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
56 changed files with 5725 additions and 811 deletions

View File

@@ -18,7 +18,7 @@ env:
jobs:
meta:
runs-on:
- mileograph
- tgj-arc
outputs:
base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }}
@@ -317,7 +317,7 @@ jobs:
release-dev:
runs-on:
- mileograph
- tgj-arc
needs:
- meta
- android-build
@@ -423,7 +423,7 @@ jobs:
release-master:
runs-on:
- mileograph
- tgj-arc
needs:
- meta
- 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/authservice.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/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart';
@@ -17,9 +19,15 @@ class App extends StatelessWidget {
ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(),
),
ChangeNotifierProvider<AccentColorService>(
create: (_) => AccentColorService(),
),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ChangeNotifierProvider<ThemeModeService>(
create: (_) => ThemeModeService(),
),
ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) {
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:mileograph_flutter/objects/objects.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 './route_summary_widget.dart';
@@ -133,6 +134,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
bool _loadingStations = false;
RouteResult? _routeResult;
List<String>? _calculatedStations;
RouteResult? get result => _routeResult;
String? _errorMessage;
@@ -178,6 +180,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (cleaned.length < 2) {
setState(() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = 'Add at least two stations before calculating.';
});
return;
@@ -185,6 +188,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() {
_errorMessage = null;
_routeResult = null;
_calculatedStations = null;
});
final api = context.read<ApiService>(); // context is valid here
try {
@@ -195,6 +199,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (res is Map && res['error'] == false) {
setState(() {
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
_calculatedStations = List.from(cleaned);
});
final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance);
@@ -205,17 +210,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
).msg;
});
} else {
setState(() => _errorMessage = 'Failed to calculate route.');
setState(() {
_errorMessage = 'Failed to calculate route.';
_calculatedStations = null;
});
}
} 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() {
final data = context.read<DataService>();
setState(() {
data.stations.add('');
_markRouteDirty();
});
}
@@ -223,6 +241,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>();
setState(() {
data.stations.removeAt(index);
_markRouteDirty();
});
}
@@ -230,6 +249,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>();
setState(() {
data.stations[index] = value;
_markRouteDirty();
});
}
@@ -237,14 +257,91 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>();
setState(() {
data.stations = [''];
_routeResult = null;
_errorMessage = null;
_markRouteDirty();
});
}
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
Widget build(BuildContext context) {
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(
children: [
Align(
@@ -262,7 +359,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_MultiSelectFilter(
MultiSelectFilter(
label: 'Countries',
options: _countries,
selected: _selectedCountries,
@@ -271,7 +368,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
_loadStations();
},
),
_MultiSelectFilter(
MultiSelectFilter(
label: 'Networks',
options: _networks,
selected: _selectedNetworks,
@@ -279,6 +376,16 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() => _selectedNetworks = vals);
_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)
const Padding(
@@ -301,6 +408,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() {
final moved = data.stations.removeAt(oldIndex);
data.stations.insert(newIndex, moved);
_markRouteDirty();
});
},
children: List.generate(data.stations.length, (index) {
@@ -364,54 +472,92 @@ class _RouteCalculatorState extends State<RouteCalculator> {
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
SizedBox.shrink(),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 8,
child: isCompact
? Column(
children: [
...(() {
final reverseButton = ElevatedButton.icon(
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
);
final addButton = ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Station'),
),
const SizedBox(width: 12),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
],
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
);
final calculateButton = ElevatedButton.icon(
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
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,
);
final isMobile = MediaQuery.of(context).size.width < 600;
return isMobile
? [addButton, reverseButton, calculateButton]
: [reverseButton, addButton, calculateButton];
})(),
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,
),
],
),
),
@@ -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 VoidCallback onBack;
final Set<String> routingPoints;
final VoidCallback? onNetworksPressed;
const RouteDetailsView({
super.key,
@@ -47,6 +48,7 @@ class RouteDetailsView extends StatelessWidget {
required this.costs,
required this.onBack,
this.routingPoints = const {},
this.onNetworksPressed,
});
@override
@@ -56,13 +58,21 @@ class RouteDetailsView extends StatelessWidget {
final mutedColor = Theme.of(context).colorScheme.outlineVariant;
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
Row(
children: [
TextButton.icon(
onPressed: onBack,
icon: const Icon(Icons.arrow_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(
child: ListView.builder(

View File

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

View File

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

View File

@@ -32,6 +32,8 @@ class _LegCardState extends State<LegCard> {
final sharedTo = leg.sharedTo;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route);
final networkMileage = _sortedNetworkMileage(leg);
final countryMileage = _sortedCountryMileage(leg);
final textTheme = Theme.of(context).textTheme;
return Card(
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(
Text(
leg.network,
networkSummary,
style: textTheme.labelSmall,
),
);
@@ -285,6 +288,28 @@ class _LegCardState extends State<LegCard> {
),
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) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
@@ -483,6 +508,33 @@ class _LegCardState extends State<LegCard> {
List<String> _parseRouteSegments(List<String> route) {
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 {

View File

@@ -1,4 +1,6 @@
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:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
@@ -41,17 +43,29 @@ class _LoginScreenState extends State<LoginScreen> {
resizeToAvoidBottomInset: true,
body: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
child: Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text.rich(
LayoutBuilder(
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,
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
const TextSpan(
@@ -61,7 +75,9 @@ class _LoginScreenState extends State<LoginScreen> {
TextSpan(
text: "graph",
style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color,
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
],
@@ -72,22 +88,14 @@ class _LoginScreenState extends State<LoginScreen> {
fontSize: 50,
),
),
softWrap: false,
overflow: TextOverflow.visible,
),
const SizedBox(height: 50),
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: 40),
if (_checkingSession) ...[
const SizedBox(height: 12),
Row(
@@ -105,15 +113,90 @@ class _LoginScreenState extends State<LoginScreen> {
),
],
),
] 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 Padding(
padding: EdgeInsets.only(bottom: 12),
child: _LoginLogo(),
),
],
),
),
),
);
}
}
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 {
const LoginPanel({super.key});

View File

@@ -449,6 +449,23 @@ class _BadgesPageState extends State<BadgesPage> {
ClassClearanceProgress progress,
) {
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(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
@@ -471,9 +488,55 @@ class _BadgesPageState extends State<BadgesPage> {
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: progress.total == 0 ? 0 : pct / 100,
minHeight: 6,
if (showActive)
Padding(
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)
Padding(

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/calculator/route_summary_widget.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 {
const CalculatorDetailsPage({
@@ -34,13 +36,85 @@ class CalculatorDetailsPage extends StatelessWidget {
);
}
return Padding(
final networks = List<NetworkMileage>.from(parsed.networkMileage)
..sort((a, b) => b.miles.compareTo(a.miles));
final countries = List<CountryMileage>.from(parsed.countryMileage)
..sort((a, b) => b.miles.compareTo(a.miles));
return Scaffold(
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:go_router/go_router.dart';
import 'package:intl/intl.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/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/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
@@ -19,6 +23,18 @@ class Dashboard extends StatefulWidget {
class _DashboardState extends State<Dashboard> {
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
Widget build(BuildContext context) {
@@ -31,16 +47,11 @@ class _DashboardState extends State<Dashboard> {
return RefreshIndicator(
onRefresh: () async {
await data.fetchHomepageStats();
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]);
await _refreshDashboardData(force: true);
},
child: LayoutBuilder(
builder: (context, constraints) {
_handleRouteFocus();
const spacing = 16.0;
final maxWidth = constraints.maxWidth;
return Stack(
@@ -97,9 +108,6 @@ class _DashboardState extends State<Dashboard> {
final totalMileage = stats?.totalMileage ?? 0;
final currentYearMileage = data.getMileageForCurrentYear();
final legCount = stats?.legCount ?? data.trips.length;
final progress = totalMileage == 0
? 0.0
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
return Card(
clipBehavior: Clip.antiAlias,
@@ -125,62 +133,248 @@ class _DashboardState extends State<Dashboard> {
spacing: 12,
runSpacing: 12,
children: [
_metricTile(
_animatedMetricTile(
context,
label: 'Total mileage',
value: distanceUnits.format(totalMileage, decimals: 1),
value: totalMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.route,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
_animatedMetricTile(
context,
label: 'This year',
value: distanceUnits.format(currentYearMileage, decimals: 1),
value: currentYearMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
_animatedMetricTile(
context,
label: 'Entries logged',
value: legCount.toString(),
value: legCount.toDouble(),
formatter: (val) => val.round().toString(),
icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer,
),
],
),
const SizedBox(height: 16),
ClipRRect(
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),
),
),
_buildClassClearanceCarousel(context, data, colorScheme),
],
),
),
);
}
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, {
required String label,
required String value,
required double value,
required String Function(double) formatter,
required IconData icon,
required Color color,
}) {
@@ -207,8 +401,9 @@ class _DashboardState extends State<Dashboard> {
letterSpacing: 0.4,
),
),
Text(
value,
AnimatedCountText(
value: value,
formatter: formatter,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: color,
@@ -555,8 +750,10 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
Text(
distanceUnits.format(trip.tripMileage, decimals: 1),
AnimatedCountText(
value: trip.tripMileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: Theme.of(context).textTheme.labelMedium,
),
],
@@ -573,4 +770,31 @@ class _DashboardState extends State<Dashboard> {
String _formatTime(DateTime 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: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/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
@@ -19,6 +20,9 @@ class _LegsPageState extends State<LegsPage> {
bool _initialised = false;
bool _unallocatedOnly = false;
bool _showMoreFilters = false;
bool _loadingNetworks = false;
List<String> _availableNetworks = [];
List<String> _selectedNetworks = [];
@override
void didChangeDependencies() {
@@ -26,9 +30,21 @@ class _LegsPageState extends State<LegsPage> {
if (!_initialised) {
_initialised = true;
_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 {
final data = context.read<DataService>();
await data.fetchLegs(
@@ -36,6 +52,7 @@ class _LegsPageState extends State<LegsPage> {
dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate),
unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks,
);
}
@@ -48,6 +65,7 @@ class _LegsPageState extends State<LegsPage> {
offset: data.legs.length,
append: true,
unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks,
);
}
@@ -90,6 +108,7 @@ class _LegsPageState extends State<LegsPage> {
_sortDirection = 0;
_unallocatedOnly = false;
_showMoreFilters = false;
_selectedNetworks = [];
});
_refreshLegs();
}
@@ -209,6 +228,16 @@ class _LegsPageState extends State<LegsPage> {
spacing: 12,
runSpacing: 12,
children: [
MultiSelectFilter(
label: 'Networks',
options: _availableNetworks,
selected: _selectedNetworks,
onChanged: (vals) async {
setState(() => _selectedNetworks = vals);
await _refreshLegs();
},
onRefresh: _loadingNetworks ? null : _loadNetworks,
),
FilterChip(
avatar: const Icon(Icons.flash_off),
label: const Text('Unallocated only'),
@@ -218,6 +247,15 @@ class _LegsPageState extends State<LegsPage> {
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/data_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'loco_timeline/timeline_grid.dart';
part 'loco_timeline/event_editor.dart';
@@ -17,25 +18,41 @@ class LocoTimelinePage extends StatefulWidget {
super.key,
required this.locoId,
required this.locoLabel,
this.forceShowPending = false,
});
final int locoId;
final String locoLabel;
final bool forceShowPending;
@override
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
}
class _LocoTimelinePageState extends State<LocoTimelinePage> {
static const String _prefsKeyShowPending = 'timeline_show_pending';
final List<_EventDraft> _draftEvents = [];
bool _isSaving = false;
bool _isDeleting = false;
bool _isModerating = false;
final Set<int> _moderatingEventIds = {};
final Set<String> _expandedPendingAttrs = {};
bool _showPending = true;
@override
void 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) {
@@ -65,10 +82,35 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
data.fetchEventFields();
return data.fetchLocoTimeline(
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() {
setState(() {
_draftEvents.add(_EventDraft());
@@ -247,7 +289,6 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
LocoAttrVersion entry,
_PendingModerationAction action,
) async {
if (_isModerating) return;
final eventId = entry.sourceEventId;
if (eventId == null) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -257,6 +298,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
);
return;
}
if (_moderatingEventIds.contains(eventId)) return;
final data = context.read<DataService>();
final approve = action == _PendingModerationAction.approve;
final messenger = ScaffoldMessenger.of(context);
@@ -283,7 +325,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (ok != true || !mounted) return;
setState(() {
_isModerating = true;
_moderatingEventIds.add(eventId);
});
try {
if (approve) {
@@ -310,7 +352,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
} finally {
if (mounted) {
setState(() {
_isModerating = false;
_moderatingEventIds.remove(eventId);
});
}
}
@@ -499,7 +541,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId);
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
final isLoading = data.isLocoTimelineLoading(widget.locoId);
final visibleTimeline = (!isElevated || _showPending)
? timeline
: timeline.where((entry) => !entry.isPending).toList();
return Scaffold(
appBar: AppBar(
@@ -516,7 +562,32 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (isLoading && timeline.isEmpty) {
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(
padding: const EdgeInsets.all(16.0),
child: Card(
@@ -550,15 +621,49 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
return ListView(
padding: const EdgeInsets.all(16),
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(
entries: timeline,
entries: visibleTimeline,
onEditEntry: (entry) => _prefillDraftFromEntry(
entry,
data.eventFields,
),
onDeleteEntry: _deleteEntry,
onModeratePending: _moderatePendingEntry,
pendingActionsBusy: _isModerating,
pendingActionEventIds: _moderatingEventIds,
expandedPendingAttrs: _expandedPendingAttrs,
onTogglePendingAttr: (attrCode) {
setState(() {
if (!_expandedPendingAttrs.add(attrCode)) {
_expandedPendingAttrs.remove(attrCode);
}
});
},
),
const SizedBox(height: 16),
_EventEditor(

View File

@@ -10,7 +10,9 @@ class _TimelineGrid extends StatefulWidget {
this.onEditEntry,
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionsBusy = false,
this.pendingActionEventIds = const {},
this.expandedPendingAttrs = const {},
this.onTogglePendingAttr,
});
final List<LocoAttrVersion> entries;
@@ -20,7 +22,9 @@ class _TimelineGrid extends StatefulWidget {
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final bool pendingActionsBusy;
final Set<int> pendingActionEventIds;
final Set<String> expandedPendingAttrs;
final void Function(String attrCode)? onTogglePendingAttr;
@override
State<_TimelineGrid> createState() => _TimelineGridState();
@@ -86,12 +90,15 @@ class _TimelineGridState extends State<_TimelineGrid> {
'build_day',
}.contains(code);
}).toList();
final model = _TimelineModel.fromEntries(filteredEntries);
final model = _TimelineModel.fromEntries(
filteredEntries,
expandedAttrCodes: widget.expandedPendingAttrs,
);
final axisSegments = model.axisSegments;
const labelWidth = 110.0;
const rowHeight = 52.0;
const double axisHeight = 48;
final rows = model.attrRows.entries.toList();
final rows = model.rows;
final totalRowsHeight = rows.length * rowHeight;
final axisWidth = math.max(model.axisTotalWidth, 120.0);
final double viewHeight = totalRowsHeight + axisHeight + 8;
@@ -131,7 +138,12 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight,
itemCount: rows.length,
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(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
@@ -148,12 +160,49 @@ class _TimelineGridState extends State<_TimelineGrid> {
),
),
),
child: Row(
children: [
if (!row.isPrimary) ...[
Icon(
Icons.subdirectory_arrow_right,
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: FontWeight.w700),
?.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,
itemCount: rows.length,
itemBuilder: (_, index) {
final blocks = rows[index].value;
final blocks = rows[index].blocks;
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 2.0),
@@ -201,7 +250,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry,
onModeratePending: widget.onModeratePending,
pendingActionsBusy: widget.pendingActionsBusy,
pendingActionEventIds: widget.pendingActionEventIds,
),
);
},
@@ -288,7 +337,7 @@ class _AttrRow extends StatelessWidget {
this.onEditEntry,
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionsBusy = false,
this.pendingActionEventIds = const {},
});
final double rowHeight;
@@ -302,7 +351,7 @@ class _AttrRow extends StatelessWidget {
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final bool pendingActionsBusy;
final Set<int> pendingActionEventIds;
@override
Widget build(BuildContext context) {
@@ -329,7 +378,7 @@ class _AttrRow extends StatelessWidget {
onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry,
onModeratePending: onModeratePending,
pendingActionsBusy: pendingActionsBusy,
pendingActionEventIds: pendingActionEventIds,
),
),
if (activeBlock != null)
@@ -346,7 +395,7 @@ class _AttrRow extends StatelessWidget {
width: stickyWidth,
),
clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
pendingActionsBusy: pendingActionsBusy,
pendingActionEventIds: pendingActionEventIds,
),
),
),
@@ -368,12 +417,12 @@ class _ValueBlockView extends StatelessWidget {
const _ValueBlockView({
required this.block,
this.clipLeftEdge = false,
this.pendingActionsBusy = false,
this.pendingActionEventIds = const {},
});
final _ValueBlock block;
final bool clipLeftEdge;
final bool pendingActionsBusy;
final Set<int> pendingActionEventIds;
@override
Widget build(BuildContext context) {
@@ -384,6 +433,11 @@ class _ValueBlockView extends StatelessWidget {
? Colors.white
: Colors.black87;
final entry = block.entry;
final eventId = entry?.sourceEventId;
final isPendingAction =
entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId);
final radius = BorderRadius.only(
topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
@@ -425,7 +479,7 @@ class _ValueBlockView extends StatelessWidget {
child: SizedBox(
width: 16,
height: 16,
child: pendingActionsBusy
child: isPendingAction
? CircularProgressIndicator(
strokeWidth: 2,
valueColor:
@@ -484,7 +538,7 @@ class _ValueBlockMenu extends StatelessWidget {
this.onEditEntry,
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionsBusy = false,
this.pendingActionEventIds = const {},
});
final _ValueBlock block;
@@ -494,7 +548,7 @@ class _ValueBlockMenu extends StatelessWidget {
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final bool pendingActionsBusy;
final Set<int> pendingActionEventIds;
bool get _hasActions {
final canModerate = block.entry?.isPending == true &&
@@ -515,6 +569,9 @@ class _ValueBlockMenu extends StatelessWidget {
block.entry?.canModeratePending == true &&
onModeratePending != null;
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 {
final overlay = Overlay.of(context);
@@ -540,13 +597,13 @@ class _ValueBlockMenu extends StatelessWidget {
if (canModerate)
PopupMenuItem(
value: _TimelineBlockAction.approve,
enabled: !pendingActionsBusy,
enabled: !isPendingAction,
child: const Text('Approve pending'),
),
if (canModerate)
PopupMenuItem(
value: _TimelineBlockAction.reject,
enabled: !pendingActionsBusy,
enabled: !isPendingAction,
child: const Text('Reject pending'),
),
if (onDeleteEntry != null)
@@ -588,7 +645,7 @@ class _ValueBlockMenu extends StatelessWidget {
},
child: _ValueBlockView(
block: block,
pendingActionsBusy: pendingActionsBusy,
pendingActionEventIds: pendingActionEventIds,
),
);
}
@@ -649,47 +706,43 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
return end;
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final Map<String, List<_ValueBlock>> attrRows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
final start = _effectiveStart(entry);
if (start == null) return false;
return approvedStartKeys.contains(_startKey(start));
}
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in effectiveEntries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) {
items.sort(
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 < items.length; i++) {
final entry = items[i];
for (int i = 0; i < sorted.length; i++) {
final entry = sorted[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
final nextStart = i < sorted.length - 1
? _effectiveStart(sorted[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
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(
@@ -699,29 +752,264 @@ class _TimelineModel {
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
}
attrSegments[attr] = segments;
});
return segments;
}
minStart ??= now.subtract(const Duration(days: 1));
final effectiveMaxEnd = maxEnd ?? now;
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 segments in attrSegments.values) {
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 = [minStart!, effectiveMaxEnd];
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 {
final List<_AxisSegment> axisSegments;
final List<_TimelineRowData> rows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({
required this.axisSegments,
required this.rows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(
List<LocoAttrVersion> entries, {
Set<String> expandedAttrCodes = const {},
}) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{};
final attrOrder = <String>[];
for (final entry in effectiveEntries) {
final key = entry.attrCode;
if (!grouped.containsKey(key)) {
attrOrder.add(key);
}
grouped.putIfAbsent(key, () => []).add(entry);
}
final now = DateTime.now();
final allSegments = <_ValueSegment>[];
final rowSpecs = <_TimelineRowSpec>[];
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);
final approvedStartKeys = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
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,
),
);
allSegments.addAll(baseSegments);
if (shouldShowPendingRows) {
final users = isExpanded
? pendingByUser.keys.toList()
: overlapByUser.keys.toList();
users.sort();
for (final user in users) {
final pendingEntries = isExpanded
? (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);
}
}
}
final boundaries = _buildBoundaries(allSegments, now);
final axisSegments = <_AxisSegment>[];
const double yearWidth = 240.0;
@@ -745,10 +1033,10 @@ class _TimelineModel {
final axisTotalWidth =
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{};
for (final entry in attrSegments.entries) {
final rows = <_TimelineRowData>[];
for (final spec in rowSpecs) {
final blocks = <_ValueBlock>[];
for (final seg in entry.value) {
for (final seg in spec.segments) {
final left = _positionForDate(seg.start, boundaries, axisSegments);
final right = _positionForDate(seg.end, boundaries, axisSegments);
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(
axisSegments: axisSegments,
attrRows: attrRows,
rows: rows,
endLabel: endLabel,
boundaries: boundaries,
axisTotalWidth: axisTotalWidth,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart';
@@ -28,6 +29,16 @@ class _AdminPageState extends State<AdminPage> {
bool _sending = false;
List<XFile> _routeFiles = [];
bool _routeUploading = false;
String? _routeStatus;
String? _routeStatusMessage;
String? _routeErrorMessage;
int? _routeProcessed;
int? _routeTotal;
double? _routeProgress;
Map<String, dynamic>? _routeResult;
@override
void initState() {
super.initState();
@@ -340,6 +351,191 @@ class _AdminPageState extends State<AdminPage> {
}
}
int? _parseCount(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
double? _parsePercent(
dynamic value, {
required int? processed,
required int? total,
}) {
if (value is num) {
final raw = value.toDouble();
final normalized = raw > 1 ? raw / 100 : raw;
return normalized.clamp(0, 1);
}
if (processed != null && total != null && total > 0) {
return (processed / total).clamp(0, 1);
}
return null;
}
Duration _pollDelay(int attempt) {
const delays = [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 2),
Duration(seconds: 5),
Duration(seconds: 5),
Duration(seconds: 8),
Duration(seconds: 10),
];
if (attempt < delays.length) return delays[attempt];
return const Duration(seconds: 10);
}
String _routeStatusLabel() {
final status = _routeStatus ?? '';
final lower = status.toLowerCase();
final base = switch (lower) {
'queued' => 'Queued',
'running' => 'Processing',
'succeeded' => 'Completed',
'failed' => 'Failed',
_ => status,
};
final parts = <String>[base];
if (_routeProcessed != null && _routeTotal != null) {
parts.add('Files $_routeProcessed of $_routeTotal');
}
if (_routeProgress != null) {
parts.add('${(_routeProgress! * 100).toStringAsFixed(0)}%');
}
return parts.join(' · ');
}
Future<void> _pickRouteFiles() async {
final files = await openFiles(
acceptedTypeGroups: const [
XTypeGroup(
label: 'XLSX spreadsheets',
extensions: ['xlsx'],
),
],
);
if (files.isEmpty) return;
setState(() {
_routeFiles = files;
_routeStatus = null;
_routeStatusMessage = null;
_routeErrorMessage = null;
_routeProcessed = null;
_routeTotal = null;
_routeProgress = null;
_routeResult = null;
});
}
Future<void> _uploadRouteFiles() async {
if (_routeFiles.isEmpty || _routeUploading) return;
setState(() {
_routeUploading = true;
_routeStatus = null;
_routeStatusMessage = null;
_routeErrorMessage = null;
_routeProcessed = null;
_routeTotal = null;
_routeProgress = null;
_routeResult = null;
});
try {
final api = context.read<ApiService>();
final payloads = <MultipartFilePayload>[];
for (final file in _routeFiles) {
final bytes = await file.readAsBytes();
payloads.add(
MultipartFilePayload(
bytes: bytes,
filename: file.name,
),
);
}
final response = await api.postMultipartFiles(
'/route/update',
files: payloads,
headers: const {'accept': 'application/json'},
);
if (!mounted) return;
final parsed = response is Map
? Map<String, dynamic>.from(response)
: null;
final jobId = parsed?['job_id']?.toString();
if (jobId == null || jobId.isEmpty) {
setState(() {
_routeErrorMessage = 'Upload failed to start.';
});
return;
}
setState(() {
_routeStatus = parsed?['status']?.toString() ?? 'queued';
});
var attempt = 0;
while (mounted) {
final statusResponse = await api.get('/uploads/$jobId');
if (!mounted) return;
final statusMap = statusResponse is Map
? Map<String, dynamic>.from(statusResponse)
: null;
if (statusMap == null) {
setState(() {
_routeErrorMessage = 'Upload status unavailable.';
});
return;
}
final status = statusMap['status']?.toString() ?? 'queued';
final processed = _parseCount(statusMap['processed']);
final total = _parseCount(statusMap['total']);
final percent = _parsePercent(
statusMap['percent'],
processed: processed,
total: total,
);
setState(() {
_routeStatus = status;
_routeProcessed = processed;
_routeTotal = total;
_routeProgress = percent;
});
if (status == 'succeeded') {
final result = statusMap['result'];
setState(() {
if (result is Map) {
_routeResult = Map<String, dynamic>.from(result);
}
final message = _routeResult?['message']?.toString();
_routeStatusMessage = message != null && message.isNotEmpty
? message
: 'Route update complete.';
});
return;
}
if (status == 'failed') {
setState(() {
_routeErrorMessage =
statusMap['error']?.toString() ?? 'Route update failed.';
});
return;
}
await Future.delayed(_pollDelay(attempt));
attempt += 1;
}
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
_routeErrorMessage = e.message;
});
} catch (e) {
if (!mounted) return;
setState(() {
_routeErrorMessage = e.toString();
});
} finally {
if (mounted) setState(() => _routeUploading = false);
}
}
void _showSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
@@ -443,6 +639,87 @@ class _AdminPageState extends State<AdminPage> {
label: Text(_sending ? 'Sending...' : 'Send notification'),
),
),
const SizedBox(height: 32),
const Divider(height: 32),
Text(
'Route update uploads',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Upload one or more XLSX sheets to update route distances.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
_routeFiles.isEmpty
? 'No files selected'
: '${_routeFiles.length} file(s) selected',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _routeUploading ? null : _pickRouteFiles,
icon: const Icon(Icons.upload_file),
label: const Text('Choose files'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed:
_routeFiles.isEmpty || _routeUploading ? null : _uploadRouteFiles,
icon: _routeUploading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.file_upload),
label: Text(_routeUploading ? 'Uploading...' : 'Upload files'),
),
if (_routeStatus != null) ...[
const SizedBox(height: 12),
Text(
_routeStatusLabel(),
style: Theme.of(context).textTheme.bodyMedium,
),
if (_routeProgress != null) ...[
const SizedBox(height: 6),
LinearProgressIndicator(value: _routeProgress),
],
],
if (_routeStatusMessage != null) ...[
const SizedBox(height: 12),
Text(
_routeStatusMessage!,
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (_routeErrorMessage != null) ...[
const SizedBox(height: 12),
Text(
_routeErrorMessage!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
if ((_routeStatus == 'failed' || _routeErrorMessage != null) &&
_routeFiles.isNotEmpty) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _routeUploading ? null : _uploadRouteFiles,
icon: const Icon(Icons.refresh),
label: const Text('Retry upload'),
),
],
],
),
);

View File

@@ -130,11 +130,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
int decimals = 1,
bool includeUnit = true,
}) {
return units.format(
miles,
decimals: decimals,
includeUnit: includeUnit,
);
return units.format(miles, decimals: decimals, includeUnit: includeUnit);
}
String _manualMileageLabel(DistanceUnit unit) {
@@ -191,15 +187,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
}
double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit)
.parseInputMiles(_mileageController.text.trim()) ??
return DistanceFormatter(
unit,
).parseInputMiles(_mileageController.text.trim()) ??
0;
}
List<UserSummary> _friendsFromFriendships(
DataService data,
String? selfId,
) {
List<UserSummary> _friendsFromFriendships(DataService data, String? selfId) {
final friends = <UserSummary>[];
for (final f in data.friendships) {
final other = _friendFromFriendship(f, selfId);
@@ -300,9 +294,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (!mounted) return;
final baseFriends = _friendsFromFriendships(data, auth.userId);
final initialSelectedIds = {..._shareUserIds};
final initialSelectedUsers = {
for (final u in _shareUsers) u.userId: u,
};
final initialSelectedUsers = {for (final u in _shareUsers) u.userId: u};
final result = await showModalBottomSheet<List<UserSummary>>(
context: context,
@@ -334,8 +326,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
searchError = null;
});
try {
final results =
await data.searchUsers(trimmed, friendsOnly: true);
final results = await data.searchUsers(
trimmed,
friendsOnly: true,
);
setModalState(() {
searchResults = results;
});
@@ -414,8 +408,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
itemCount: list.length,
itemBuilder: (_, index) {
final user = list[index];
final isSelected =
selectedIds.contains(user.userId);
final isSelected = selectedIds.contains(
user.userId,
);
return CheckboxListTile(
value: isSelected,
title: Text(user.displayName),
@@ -481,8 +476,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (previousUnit == currentUnit) return;
final miles = _milesFromInputWithUnit(previousUnit);
final nextText = DistanceFormatter(currentUnit)
.format(miles, decimals: 2, includeUnit: false);
final nextText = DistanceFormatter(
currentUnit,
).format(miles, decimals: 2, includeUnit: false);
_mileageController.text = nextText;
_lastDistanceUnit = currentUnit;
}
@@ -842,8 +838,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
final destination = json['leg_destination'] as String? ?? '';
final hasEndTime = endTime != null || endDelay != 0;
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
final destinationTime =
DateTime.tryParse(json['leg_destination_time'] ?? '');
final destinationTime = DateTime.tryParse(
json['leg_destination_time'] ?? '',
);
final hasOriginTime = originTime != null;
final hasDestinationTime = destinationTime != null;
@@ -860,8 +857,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime =
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual;
@@ -927,7 +925,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
);
final tractionItems = _buildTractionFromApi(
entry.locos
.map((l) => {
.map(
(l) => {
"loco_id": l.id,
"type": l.type,
"number": l.number,
@@ -938,7 +937,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
"evn": l.evn,
"alloc_pos": l.allocPos,
"alloc_powering": l.powering ? 1 : 0,
})
},
)
.toList(),
);
final beginDelay = entry.beginDelayMinutes ?? 0;
@@ -963,8 +963,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime =
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual;
@@ -980,12 +981,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
_endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0
? ''
: _formatDistance(
units,
mileageVal,
decimals: 2,
includeUnit: false,
);
: _formatDistance(units, mileageVal, decimals: 2, includeUnit: false);
_tractionItems
..clear()
..addAll(tractionItems);
@@ -1187,10 +1183,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
],
if (!matchValue) ...[
_stationField(
label: label,
controller: controller,
),
_stationField(label: label, controller: controller),
CheckboxListTile(
value: hasTime,
onChanged: onTimeChanged,
@@ -1237,8 +1230,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed:
_isEditing || _activeLegShare != null ? null : _openDrafts,
onPressed: _isEditing || _activeLegShare != null
? null
: _openDrafts,
icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'),
),
@@ -1249,7 +1243,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing ||
onPressed:
_isEditing ||
_savingDraft ||
_submitting ||
_activeLegShare != null
@@ -1393,8 +1388,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
onTimeChanged: _submitting ? null : _toggleDestinationTime,
matchLabel: 'Match entry end',
matchValue: _matchDestinationToEntry,
onMatchChanged:
_submitting ? null : _toggleMatchDestination,
onMatchChanged: _submitting ? null : _toggleMatchDestination,
pickerBuilder: () => _dateTimeGroupSimple(
context,
title: 'Destination arrival',
@@ -1405,6 +1399,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
singleColumn: true,
),
),
if (_useManualMileage) ...[
const Divider(height: 24),
TextFormField(
controller: _networkController,
@@ -1415,6 +1410,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
border: OutlineInputBorder(),
),
),
],
TextFormField(
controller: _notesController,
maxLines: 3,
@@ -1428,7 +1424,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final tractionPanel = _section('Traction', [
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
child: FilledButton.icon(
onPressed: _openTractionPicker,
icon: const Icon(Icons.search),
label: const Text('Search traction'),
@@ -1456,12 +1452,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
FilledButton.icon(
onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'),
),
OutlinedButton.icon(
TextButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
@@ -1493,8 +1489,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
decoration: InputDecoration(
labelText: mileageLabel,
helperText: currentDistanceUnit ==
DistanceUnit.milesChains
helperText:
currentDistanceUnit == DistanceUnit.milesChains
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
: null,
border: const OutlineInputBorder(),
@@ -1526,6 +1522,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
onSelected: (val) {
setState(() {
_useManualMileage = val;
if (!val) {
_networkController.clear();
}
if (val && _routeResult != null) {
_mileageController.text = _formatDistance(
distanceUnitService,
@@ -1571,7 +1570,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
@@ -1842,7 +1841,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
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);
if (existingIndex >= 0) return;
@@ -2016,7 +2019,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
],
);
}
}
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');
}
@@ -104,6 +104,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
if (_useManualMileage)
"leg_network": _networkController.text.trim(),
"leg_origin": _originController.text.trim(),
"leg_destination": _destinationController.text.trim(),

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@@ -31,6 +34,10 @@ class _ProfilePageState extends State<ProfilePage> {
bool _showAccountSettings = false;
bool _changingPassword = false;
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;
Friendship? _status;
@@ -71,6 +78,54 @@ class _ProfilePageState extends State<ProfilePage> {
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 {
final query = _searchController.text.trim();
if (query.isEmpty) {
@@ -127,6 +182,9 @@ class _ProfilePageState extends State<ProfilePage> {
);
if (!mounted) return;
setState(() => _status = status);
final data = context.read<DataService>();
await data.fetchFriendships();
await data.fetchPendingFriendships();
_showSnack('Friend request sent');
} catch (e) {
_showSnack('Failed to send request: $e');
@@ -159,6 +217,7 @@ class _ProfilePageState extends State<ProfilePage> {
final updated = await context.read<DataService>().acceptFriendship(id);
if (!mounted) return;
setState(() => _status = updated);
await context.read<DataService>().fetchFriendships();
_showSnack('Friend request accepted');
} catch (e) {
_showSnack('Failed to accept: $e');
@@ -175,6 +234,7 @@ class _ProfilePageState extends State<ProfilePage> {
final updated = await context.read<DataService>().rejectFriendship(id);
if (!mounted) return;
setState(() => _status = updated);
await context.read<DataService>().fetchPendingFriendships();
_showSnack('Friend request rejected');
} catch (e) {
_showSnack('Failed to reject: $e');
@@ -824,6 +884,8 @@ class _ProfilePageState extends State<ProfilePage> {
firstChild: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildExportSection(theme),
const SizedBox(height: 12),
if (showPrivacySpinner)
const Padding(
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) {
final selfId = currentUserId ?? '';
if (friendship.requester?.userId == selfId) return friendship.addressee;

View File

@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
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/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
@@ -18,6 +20,18 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController;
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
void initState() {
@@ -130,7 +144,12 @@ class _SettingsPageState extends State<SettingsPage> {
Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>();
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(
body: Center(child: CircularProgressIndicator()),
);
@@ -184,6 +203,73 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
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(
'API endpoint',
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),
_buildTypeCountsSection(context, year),
_buildSection<StatsClassMileage>(
context,
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>(
context,
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>(
BuildContext context, {
required String title,

View File

@@ -1,13 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
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/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

File diff suppressed because it is too large Load Diff

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.onReplacePending,
this.onActionComplete,
this.onTransferAllocations,
});
final LocoSummary loco;
@@ -31,6 +32,7 @@ class TractionCard extends StatelessWidget {
final VoidCallback? onToggleSelect;
final VoidCallback? onReplacePending;
final Future<void> Function()? onActionComplete;
final VoidCallback? onTransferAllocations;
@override
Widget build(BuildContext context) {
@@ -145,7 +147,13 @@ class TractionCard extends StatelessWidget {
];
// Prefer replace action when picking a replacement loco.
final addButton = onReplacePending != null
final addButton = onTransferAllocations != null
? TextButton.icon(
onPressed: onTransferAllocations,
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer'),
)
: onReplacePending != null
? TextButton.icon(
onPressed: onReplacePending,
icon: const Icon(Icons.swap_horiz),
@@ -159,7 +167,8 @@ class TractionCard extends StatelessWidget {
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
label:
Text(isSelected ? 'Remove' : 'Add to entry'),
)
: null;
@@ -551,6 +560,7 @@ Future<void> showTractionDetails(
LocoSummary loco, {
Future<void> Function()? onActionComplete,
}) async {
final navContext = context;
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final isVisibilityPending =
(loco.visibility ?? '').toLowerCase().trim() == 'pending';
@@ -713,8 +723,72 @@ Future<void> showTractionDetails(
);
},
),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 16),
if (hasMileageOrTrips)
FilledButton.icon(
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': '0',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': false,
},
);
},
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
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:
@@ -733,11 +807,13 @@ Future<void> showTractionDetails(
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
onPressed: () =>
Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
onPressed: () =>
Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.error,
@@ -759,12 +835,16 @@ Future<void> showTractionDetails(
Navigator.of(ctx).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete loco: $e')),
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? _shareId;
Leg? _currentLeg;
LegShareData? _share;
Future<LegShareData?>? _shareFuture;
bool _loading = false;
static const int _summaryLimit = 3;
@@ -33,11 +35,11 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.notification != widget.notification) {
_parseNotification();
_parseNotification(notify: true);
}
}
void _parseNotification() {
void _parseNotification({bool notify = false}) {
final rawBody = widget.notification.body.trim();
// Reset
@@ -45,11 +47,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
_legId = null;
_currentLeg = null;
_changes = null;
_share = null;
_shareFuture = null;
final parsed = _decodeBody(rawBody);
if (parsed != null) {
_shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']);
_currentLeg = _findCurrentLeg(_legId);
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) {
_changes = accepted;
@@ -58,6 +63,10 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
// Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody);
_prepareShareFuture();
if (notify) {
setState(() {});
}
}
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) {
if (legId == null) return null;
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
Widget build(BuildContext context) {
@@ -184,10 +285,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
final entries = changes.entries.toList();
final shown = entries.take(_summaryLimit).toList();
final remaining = entries.length - shown.length;
final legSummary = _buildLegSummary(context);
final hasSummary = _shareFuture != null || _currentLeg != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasSummary) legSummary,
if (hasSummary) const SizedBox(height: 8),
...shown.map((e) => _changePreview(context, e)),
if (remaining > 0)
Padding(
@@ -325,6 +430,7 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
Future<void> _openDrawer(Map<String, dynamic> changes) async {
setState(() => _loading = true);
await _loadLegIdIfNeeded();
await _loadShareIfNeeded();
_currentLeg ??= _findCurrentLeg(_legId);
if (!mounted) return;
setState(() => _loading = false);
@@ -398,7 +504,9 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: 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(
Icons.train,
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:provider/provider.dart';
class LegShareNotificationCard extends StatelessWidget {
class LegShareNotificationCard extends StatefulWidget {
const LegShareNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
State<LegShareNotificationCard> createState() => _LegShareNotificationCardState();
}
class _LegShareNotificationCardState extends State<LegShareNotificationCard> {
bool _accepting = false;
bool _rejecting = false;
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final legShareId = _extractLegShareId(notification.body);
final legShareId = _extractLegShareId(widget.notification.body);
if (legShareId == null) {
return const Text('Invalid leg share notification.');
}
@@ -78,16 +86,28 @@ class LegShareNotificationCard extends StatelessWidget {
runSpacing: 8,
children: [
ElevatedButton(
onPressed: () => _accept(context, share),
child: const Text('Accept'),
onPressed: _accepting ? null : () => _accept(context, share),
child: _accepting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Accept'),
),
OutlinedButton(
onPressed: () => _inspect(context, share),
child: const Text('Inspect'),
),
TextButton(
onPressed: () => _reject(context, share),
child: const Text('Reject'),
onPressed: _rejecting ? null : () => _reject(context, share),
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 {
setState(() => _accepting = true);
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.acceptLegShare(share);
if (!context.mounted) return;
await data.dismissNotifications([notification.id]);
await data.dismissNotifications([widget.notification.id]);
// Refresh legs in the background.
unawaited(data.refreshLegs());
messenger?.showSnackBar(
@@ -113,16 +134,21 @@ class LegShareNotificationCard extends StatelessWidget {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to add shared entry: $e')),
);
} finally {
if (mounted) {
setState(() => _accepting = false);
}
}
}
Future<void> _reject(BuildContext context, LegShareData share) async {
setState(() => _rejecting = true);
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.rejectLegShare(share.id);
if (!context.mounted) return;
await data.dismissNotifications([notification.id]);
await data.dismissNotifications([widget.notification.id]);
messenger?.showSnackBar(
const SnackBar(content: Text('Share rejected')),
);
@@ -130,6 +156,10 @@ class LegShareNotificationCard extends StatelessWidget {
messenger?.showSnackBar(
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();
}
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 path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
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 classByYear = <int, List<StatsClassMileage>>{};
final networkByYear = <int, List<StatsNetworkMileage>>{};
final countryByYear = <int, List<StatsCountryMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{};
final winnerTypeCountsByYear = <int, Map<String, int>>{};
final totalTypeCountsByYear = <int, Map<String, int>>{};
void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) {
@@ -575,6 +578,17 @@ class StatsAbout {
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>(
dynamic source,
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>>(
json['top_classes'],
classByYear,
@@ -599,6 +623,11 @@ class StatsAbout {
networkByYear,
parseNetworkList,
);
parseYearMap<List<StatsCountryMileage>>(
json['top_countries'],
countryByYear,
parseCountryList,
);
parseYearMap<List<StatsStationVisits>>(
json['top_stations'],
stationByYear,
@@ -610,6 +639,19 @@ class StatsAbout {
if (year == null) return;
if (value is List) {
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,
...classByYear.keys,
...networkByYear.keys,
...countryByYear.keys,
...stationByYear.keys,
...winnersByYear.keys,
...winnerTypeCountsByYear.keys,
...totalTypeCountsByYear.keys,
}..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{};
@@ -629,8 +674,11 @@ class StatsAbout {
mileage: mileageByYear[year] ?? 0,
topClasses: classByYear[year] ?? const [],
topNetworks: networkByYear[year] ?? const [],
topCountries: countryByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0,
winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {},
totalTypeCounts: totalTypeCountsByYear[year] ?? const {},
);
}
@@ -649,16 +697,22 @@ class StatsYear {
final double mileage;
final List<StatsClassMileage> topClasses;
final List<StatsNetworkMileage> topNetworks;
final List<StatsCountryMileage> topCountries;
final List<StatsStationVisits> topStations;
final int winnerCount;
final Map<String, int> winnerTypeCounts;
final Map<String, int> totalTypeCounts;
StatsYear({
required this.year,
required this.mileage,
required this.topClasses,
required this.topNetworks,
required this.topCountries,
required this.topStations,
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 {
final String station;
final int visits;
@@ -1172,6 +1242,8 @@ class Leg {
final String start, end, network, notes, headcode, user;
final String origin, destination;
final List<String> route;
final List<NetworkMileage> networkMileage;
final List<CountryMileage> countryMileage;
final String? legShareId;
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
@@ -1198,6 +1270,8 @@ class Leg {
required this.driving,
required this.user,
required this.locos,
this.networkMileage = const [],
this.countryMileage = const [],
this.endTime,
this.originTime,
this.destinationTime,
@@ -1267,6 +1341,14 @@ class Leg {
: _asInt(json['leg_end_delay']),
origin: _asString(json['leg_origin']),
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']),
sharedFrom: sharedFrom,
sharedTo: sharedTo,
@@ -1437,12 +1519,16 @@ class RouteResult {
final List<String> calculatedRoute;
final List<double> costs;
final double distance;
final List<NetworkMileage> networkMileage;
final List<CountryMileage> countryMileage;
RouteResult({
required this.inputRoute,
required this.calculatedRoute,
required this.costs,
required this.distance,
this.networkMileage = const [],
this.countryMileage = const [],
});
factory RouteResult.fromJson(Map<String, dynamic> json) {
@@ -1451,10 +1537,50 @@ class RouteResult {
calculatedRoute: List<String>.from(json['calculated_route']),
costs: (json['costs'] as List).map((e) => (e as num).toDouble()).toList(),
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 {
final int id;
final String name;
@@ -1779,12 +1905,18 @@ class ClassClearanceProgress {
final int completed;
final int total;
final double percentComplete;
final int activeCompleted;
final int activeTotal;
final double activePercent;
ClassClearanceProgress({
required this.className,
required this.completed,
required this.total,
required this.percentComplete,
required this.activeCompleted,
required this.activeTotal,
required this.activePercent,
});
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
@@ -1802,11 +1934,34 @@ class ClassClearanceProgress {
if (percent == 0 && total > 0) {
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(
className: name.isNotEmpty ? name : 'Class',
completed: completed,
total: total,
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,19 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
typedef UnauthorizedHandler = Future<bool> Function();
class MultipartFilePayload {
MultipartFilePayload({
required this.bytes,
required this.filename,
this.fieldName,
});
final List<int> bytes;
final String filename;
final String? fieldName;
}
class ApiService {
String _baseUrl;
@@ -36,52 +48,217 @@ class ApiService {
_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 headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) {
if (includeAuth && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
final response = await _client
.get(
Future<dynamic> get(
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),
)
.timeout(timeout);
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
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(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _client
.post(
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await _client
.post(
Future<ApiBinaryResponse> postBytes(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders({
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> postMultipartFiles(
String endpoint, {
required List<MultipartFilePayload> files,
String fieldName = 'files',
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);
}
for (final file in files) {
request.files.add(
http.MultipartFile.fromBytes(
file.fieldName ?? fieldName,
file.bytes,
filename: file.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',
'accept': 'application/json',
}),
},
includeAuth: includeAuth,
),
body: data, // http package handles form-encoding for Map<String, String>
)
.timeout(timeout);
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
@@ -89,28 +266,37 @@ class ApiService {
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _client
.put(
final response = await _sendWithRetry(
() => _client.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _client
.delete(
final response = await _sendWithRetry(
() => _client.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
@@ -124,10 +310,6 @@ class ApiService {
return body;
}
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: res.statusCode,
@@ -176,6 +358,34 @@ class ApiService {
}
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 {
@@ -192,3 +402,17 @@ class ApiException implements Exception {
@override
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 {
final ApiService api;
bool _restoring = false;
String? _accessToken;
Future<bool>? _refreshFuture;
final TokenStorageService _tokenStorage = TokenStorageService();
AuthService({required this.api}) {
api.setTokenProvider(() => token);
api.setUnauthorizedHandler(handleTokenExpired);
api.setUnauthorizedHandler(_handleUnauthorized);
}
AuthenticatedUserData? _user;
bool get isLoggedIn => _user != null;
String? get token => _user?.accessToken;
String? get token => _accessToken;
String? get userId => _user?.userId;
String? get username => _user?.username;
String? get fullName => _user?.fullName;
@@ -33,11 +35,13 @@ class AuthService extends ChangeNotifier {
required String fullName,
required String accessToken,
required String email,
String? refreshToken,
String entriesVisibility = 'private',
String mileageVisibility = 'private',
bool isElevated = false,
bool isDisabled = false,
}) {
_accessToken = accessToken;
_user = AuthenticatedUserData(
userId: userId,
username: username,
@@ -49,7 +53,7 @@ class AuthService extends ChangeNotifier {
isElevated: isElevated,
disabled: isDisabled,
);
_persistToken(accessToken);
_persistTokens(accessToken, refreshToken);
notifyListeners();
}
@@ -64,8 +68,9 @@ class AuthService extends ChangeNotifier {
};
// 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 refreshToken = tokenResponse['refresh_token'];
// 2. Get user details
final userResponse = await api.get(
@@ -83,6 +88,7 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'],
accessToken: accessToken,
email: userResponse['email'],
refreshToken: refreshToken,
entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
'private',
@@ -103,20 +109,18 @@ class AuthService extends ChangeNotifier {
// read token from secure storage (with fallback)
final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return;
_accessToken = token;
final userResponse = await api.get(
'/users/me',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
);
final restoredAccessToken = _accessToken ?? token;
setLoginData(
userId: userResponse['user_id'],
username: userResponse['username'],
fullName: userResponse['full_name'],
accessToken: token,
accessToken: restoredAccessToken,
email: userResponse['email'],
entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
@@ -140,12 +144,9 @@ class AuthService extends ChangeNotifier {
final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return false;
try {
_accessToken = token;
await api.get(
'/validate',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
);
return true;
} catch (_) {
@@ -154,11 +155,15 @@ class AuthService extends ChangeNotifier {
}
}
Future<void> _persistToken(String token) async {
await _tokenStorage.setToken(token);
Future<void> _persistTokens(String accessToken, String? refreshToken) async {
await _tokenStorage.setToken(accessToken);
if (refreshToken != null && refreshToken.isNotEmpty) {
await _tokenStorage.setRefreshToken(refreshToken);
}
}
Future<void> _clearToken() async {
_accessToken = null;
await _tokenStorage.clearToken();
}
@@ -181,6 +186,61 @@ class AuthService extends ChangeNotifier {
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 {
_user = null;
await _clearToken();

View File

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

View File

@@ -7,6 +7,7 @@ class _LegFetchOptions {
final String? dateRangeStart;
final String? dateRangeEnd;
final bool unallocatedOnly;
final List<String> networkFilter;
const _LegFetchOptions({
this.limit = 100,
@@ -15,6 +16,7 @@ class _LegFetchOptions {
this.dateRangeStart,
this.dateRangeEnd,
this.unallocatedOnly = false,
this.networkFilter = const [],
});
}
@@ -52,6 +54,8 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore;
int _pendingLocoCount = 0;
int get pendingLocoCount => _pendingLocoCount;
List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false;
@@ -94,12 +98,29 @@ class DataService extends ChangeNotifier {
bool _isEventFieldsLoading = false;
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
final Map<String, List<Station>> _stationCache = {};
final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};
List<String> _stationNetworks = [];
Map<String, List<String>> _stationCountryNetworks = {};
DateTime? _stationFiltersFetchedAt;
DateTime? _stationNetworksFetchedAt;
List<String> get stationNetworks => _stationNetworks;
Map<String, List<String>> get stationCountryNetworks =>
_stationCountryNetworks;
@@ -373,9 +394,14 @@ class DataService extends ChangeNotifier {
String? dateRangeEnd,
bool append = false,
bool unallocatedOnly = false,
List<String> networkFilter = const [],
}) async {
_isLegsLoading = true;
if (!append) {
final normalizedNetworks = networkFilter
.map((network) => network.trim())
.where((network) => network.isNotEmpty)
.toList();
_lastLegsFetch = _LegFetchOptions(
limit: limit,
sortBy: sortBy,
@@ -383,6 +409,7 @@ class DataService extends ChangeNotifier {
dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd,
unallocatedOnly: unallocatedOnly,
networkFilter: normalizedNetworks,
);
}
final buffer = StringBuffer(
@@ -397,6 +424,13 @@ class DataService extends ChangeNotifier {
if (unallocatedOnly) {
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 {
final json = await api.get('/user/legs${buffer.toString()}');
@@ -426,6 +460,7 @@ class DataService extends ChangeNotifier {
dateRangeStart: _lastLegsFetch.dateRangeStart,
dateRangeEnd: _lastLegsFetch.dateRangeEnd,
unallocatedOnly: _lastLegsFetch.unallocatedOnly,
networkFilter: _lastLegsFetch.networkFilter,
);
}
@@ -651,6 +686,7 @@ class DataService extends ChangeNotifier {
_stationNetworks = [];
_stationCountryNetworks = {};
_stationFiltersFetchedAt = null;
_stationNetworksFetchedAt = null;
_notifications = [];
_isNotificationsLoading = false;
_userEntriesVisibility = 'private';
@@ -718,6 +754,9 @@ class DataService extends ChangeNotifier {
final networks = (map['networks'] as List? ?? const [])
.whereType<String>()
.toList();
networks.sort(
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
final countryNetworksRaw =
map['country_networks'] as Map? ?? const <String, dynamic>{};
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) {
final c = countries..sort();
final n = networks..sort();

View File

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

View File

@@ -64,6 +64,7 @@ extension DataServiceTraction on DataService {
_isTractionLoading = false;
_notifyAsync();
}
}
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 {
try {
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;
static const _tokenKey = 'auth_token';
static const _accessTokenKey = 'auth_token';
static const _refreshTokenKey = 'refresh_token';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
Future<SharedPreferences> get _prefs async =>
@@ -18,17 +19,17 @@ class TokenStorageService {
Future<void> setToken(String token) async {
try {
await _secureStorage.write(key: _tokenKey, value: token);
await _secureStorage.write(key: _accessTokenKey, value: token);
} catch (_) {
// ignore secure storage failures in debug/unsupported environments
}
final prefs = await _prefs;
await prefs.setString(_tokenKey, token);
await prefs.setString(_accessTokenKey, token);
}
Future<String?> getToken() async {
try {
final secured = await _secureStorage.read(key: _tokenKey);
final secured = await _secureStorage.read(key: _accessTokenKey);
if (secured != null && secured.isNotEmpty) {
return secured;
}
@@ -36,22 +37,48 @@ class TokenStorageService {
// ignore and fall back
}
final prefs = await _prefs;
final token = prefs.getString(_tokenKey);
final token = prefs.getString(_accessTokenKey);
return (token == null || token.isEmpty) ? null : token;
}
Future<void> clearToken() async {
try {
await _secureStorage.delete(key: _tokenKey);
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
} catch (_) {
// ignore
}
final prefs = await _prefs;
await prefs.remove(_tokenKey);
await prefs.remove(_accessTokenKey);
await prefs.remove(_refreshTokenKey);
}
Future<bool> hasToken() async {
final token = await getToken();
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/traction.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/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_notification_card.dart';
import 'package:mileograph_flutter/objects/objects.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/navigation_guard.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey =
@@ -103,12 +106,6 @@ class _MyAppState extends State<MyApp> {
late final GoRouter _router;
bool _routerInitialized = false;
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -188,10 +185,43 @@ class _MyAppState extends State<MyApp> {
'',
)
: 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 =
(selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null;
replacementPendingLocoId != null ||
transferFromLocoId != null;
final selectionSingle = replacementPendingLocoId != null ||
transferFromLocoId != null ||
selectionParam?.toLowerCase() == 'single' ||
selectionParam == '1' ||
selectionParam?.toLowerCase() == 'true';
@@ -199,6 +229,9 @@ class _MyAppState extends State<MyApp> {
selectionMode: selectionMode,
selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
);
},
),
@@ -206,6 +239,11 @@ class _MyAppState extends State<MyApp> {
path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(),
),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
@@ -223,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra;
}
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(
@@ -325,20 +371,34 @@ class _MyAppState extends State<MyApp> {
@override
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(
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(
title: 'Mileograph',
routerConfig: _router,
theme: ThemeData(
useMaterial3: true,
colorScheme: lightDynamic ?? defaultLight,
colorScheme: colorSchemeLight,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark,
colorScheme: colorSchemeDark,
),
themeMode: ThemeMode.system,
themeMode: themeModeService.mode,
);
},
);
@@ -494,10 +554,7 @@ class _MyHomePageState extends State<MyHomePage> {
)
.toList();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text.rich(
final logo = Text.rich(
TextSpan(
children: const [
TextSpan(text: "Mile"),
@@ -513,6 +570,17 @@ class _MyHomePageState extends State<MyHomePage> {
fontFamily: "Tomatoes",
),
),
);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: isWide
? logo
: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: logo,
),
actions: [
_buildNotificationAction(context, data),

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 on-disk name of your application.
set(BINARY_NAME "mileograph_flutter")
set(BINARY_NAME "Mileograph")
# The unique GTK application identifier for this application. See:
# 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
# versions of CMake.

View File

@@ -7,12 +7,16 @@
#include "generated_plugin_registrant.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>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

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

View File

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

View File

@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -121,6 +129,70 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description: flutter
@@ -190,6 +262,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct dev"
description: flutter
@@ -328,6 +408,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -533,6 +621,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -591,4 +703,4 @@ packages:
version: "3.1.3"
sdks:
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
# 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.
version: 0.7.0+8
version: 0.8.1+18
environment:
sdk: ^3.8.1
@@ -37,6 +37,12 @@ dependencies:
dynamic_color: ^1.6.6
flutter_secure_storage: ^10.0.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.
# Use with the CupertinoIcons class for iOS style icons.
@@ -68,6 +74,8 @@ flutter:
- family: Tomatoes
fonts:
- 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:
# assets:
# - 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 <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>
void RegisterPlugins(flutter::PluginRegistry* registry) {
DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}

View File

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