Compare commits

...

13 Commits

Author SHA1 Message Date
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
43 changed files with 3936 additions and 520 deletions

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -133,6 +133,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
bool _loadingStations = false; bool _loadingStations = false;
RouteResult? _routeResult; RouteResult? _routeResult;
List<String>? _calculatedStations;
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
String? _errorMessage; String? _errorMessage;
@@ -178,6 +179,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (cleaned.length < 2) { if (cleaned.length < 2) {
setState(() { setState(() {
_routeResult = null; _routeResult = null;
_calculatedStations = null;
_errorMessage = 'Add at least two stations before calculating.'; _errorMessage = 'Add at least two stations before calculating.';
}); });
return; return;
@@ -185,6 +187,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
_routeResult = null; _routeResult = null;
_calculatedStations = null;
}); });
final api = context.read<ApiService>(); // context is valid here final api = context.read<ApiService>(); // context is valid here
try { try {
@@ -195,6 +198,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (res is Map && res['error'] == false) { if (res is Map && res['error'] == false) {
setState(() { setState(() {
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res)); _routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
_calculatedStations = List.from(cleaned);
}); });
final distance = (_routeResult?.distance ?? 0); final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance); widget.onDistanceComputed?.call(distance);
@@ -205,17 +209,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
).msg; ).msg;
}); });
} else { } else {
setState(() => _errorMessage = 'Failed to calculate route.'); setState(() {
_errorMessage = 'Failed to calculate route.';
_calculatedStations = null;
});
} }
} catch (e) { } catch (e) {
setState(() => _errorMessage = 'Failed to calculate route: $e'); setState(() {
_errorMessage = 'Failed to calculate route: $e';
_calculatedStations = null;
});
} }
} }
void _markRouteDirty() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = null;
}
void _addStation() { void _addStation() {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.add(''); data.stations.add('');
_markRouteDirty();
}); });
} }
@@ -223,6 +240,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.removeAt(index); data.stations.removeAt(index);
_markRouteDirty();
}); });
} }
@@ -230,6 +248,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations[index] = value; data.stations[index] = value;
_markRouteDirty();
}); });
} }
@@ -237,14 +256,91 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations = ['']; data.stations = [''];
_routeResult = null; _markRouteDirty();
_errorMessage = null;
}); });
} }
bool _isResultCurrent(List<String> stations) {
if (_routeResult == null || _calculatedStations == null) return false;
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length != _calculatedStations!.length) return false;
for (var i = 0; i < cleaned.length; i++) {
if (cleaned[i] != _calculatedStations![i]) return false;
}
return true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final isCompact = MediaQuery.of(context).size.width < 600;
final showApply =
widget.onApplyRoute != null && _isResultCurrent(data.stations);
final primaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 14 : 20,
vertical: isCompact ? 10 : 14,
);
final secondaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 10 : 16,
vertical: isCompact ? 8 : 12,
);
final primaryStyle = FilledButton.styleFrom(
padding: primaryPadding,
minimumSize: Size(0, isCompact ? 38 : 46),
);
final secondaryStyle = OutlinedButton.styleFrom(
padding: secondaryPadding,
minimumSize: Size(0, isCompact ? 34 : 42),
);
Widget buildSecondaryButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
if (isCompact) {
return Tooltip(
message: label,
child: OutlinedButton(
onPressed: onPressed,
style: secondaryStyle,
child: Icon(icon, size: 20),
),
);
}
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 20),
label: Text(label),
style: secondaryStyle,
);
}
Widget buildPrimaryAction({required bool fullWidth}) {
final button = showApply
? FilledButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
style: primaryStyle,
)
: FilledButton.icon(
onPressed: () async {
await _calculateRoute(data.stations);
},
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
style: primaryStyle,
);
final key =
ValueKey<String>(showApply ? 'apply-primary-action' : 'calc-primary-action');
if (!fullWidth) return KeyedSubtree(key: key, child: button);
return KeyedSubtree(
key: key,
child: SizedBox(width: double.infinity, child: button),
);
}
return Column( return Column(
children: [ children: [
Align( Align(
@@ -301,6 +397,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
final moved = data.stations.removeAt(oldIndex); final moved = data.stations.removeAt(oldIndex);
data.stations.insert(newIndex, moved); data.stations.insert(newIndex, moved);
_markRouteDirty();
}); });
}, },
children: List.generate(data.stations.length, (index) { children: List.generate(data.stations.length, (index) {
@@ -364,54 +461,92 @@ class _RouteCalculatorState extends State<RouteCalculator> {
context.push('/calculator/details', extra: result); context.push('/calculator/details', extra: result);
}, },
), ),
if (widget.onApplyRoute != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
),
),
] ]
else else
SizedBox.shrink(), SizedBox.shrink(),
const SizedBox(height: 10), const SizedBox(height: 10),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: isCompact
alignment: WrapAlignment.center, ? Column(
spacing: 12,
runSpacing: 8,
children: [ children: [
...(() { Row(
final reverseButton = ElevatedButton.icon( mainAxisAlignment: MainAxisAlignment.center,
icon: const Icon(Icons.swap_horiz), children: [
label: const Text('Reverse route'), buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async { onPressed: () async {
setState(() { setState(() {
data.stations = data.stations.reversed.toList(); data.stations = data.stations.reversed.toList();
}); });
await _calculateRoute(data.stations); await _calculateRoute(data.stations);
}, },
); ),
final addButton = ElevatedButton.icon( const SizedBox(width: 12),
icon: const Icon(Icons.add), buildSecondaryButton(
label: const Text('Add Station'), icon: Icons.add,
label: 'Add station',
onPressed: _addStation, 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( return ScaleTransition(
icon: const Icon(Icons.route), scale:
label: const Text('Calculate Route'), 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 { onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations); 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 ScaleTransition(
return isMobile scale:
? [addButton, reverseButton, calculateButton] Tween<double>(begin: 0.94, end: 1.0).animate(curved),
: [reverseButton, addButton, calculateButton]; child: FadeTransition(opacity: animation, child: child),
})(), );
},
child: buildPrimaryAction(fullWidth: false),
),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
], ],
), ),
), ),

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/settings.dart';
@@ -41,17 +43,29 @@ class _LoginScreenState extends State<LoginScreen> {
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
body: Container( body: Container(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
child: Column(
children: [
Expanded(
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text.rich( LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text.rich(
TextSpan( TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: "Mile", text: "Mile",
style: TextStyle( style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color, color: Theme.of(
context,
).textTheme.bodyLarge?.color,
), ),
), ),
const TextSpan( const TextSpan(
@@ -61,7 +75,9 @@ class _LoginScreenState extends State<LoginScreen> {
TextSpan( TextSpan(
text: "graph", text: "graph",
style: TextStyle( style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color, color: Theme.of(
context,
).textTheme.bodyLarge?.color,
), ),
), ),
], ],
@@ -72,6 +88,12 @@ class _LoginScreenState extends State<LoginScreen> {
fontSize: 50, fontSize: 50,
), ),
), ),
softWrap: false,
overflow: TextOverflow.visible,
),
),
);
},
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
const LoginPanel(), const LoginPanel(),
@@ -110,10 +132,69 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
), ),
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.value.toRadixString(16).padLeft(8, '0');
return '#${hex.substring(2)}';
}
}
class LoginPanel extends StatefulWidget { class LoginPanel extends StatefulWidget {
const LoginPanel({super.key}); const LoginPanel({super.key});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -141,6 +141,7 @@ class _StatsPageState extends State<StatsPage> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildTypeCountsSection(context, year),
_buildSection<StatsClassMileage>( _buildSection<StatsClassMileage>(
context, context,
title: 'Top classes', title: 'Top classes',
@@ -201,6 +202,83 @@ class _StatsPageState extends State<StatsPage> {
); );
} }
List<MapEntry<String, int>> _sortedTypeCounts(Map<String, int> counts) {
final entries = counts.entries.toList();
entries.sort((a, b) {
final countCompare = b.value.compareTo(a.value);
if (countCompare != 0) return countCompare;
return a.key.compareTo(b.key);
});
return entries;
}
Widget _buildTypeCountsSection(BuildContext context, StatsYear year) {
if (year.winnerTypeCounts.isEmpty && year.totalTypeCounts.isEmpty) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Type counts',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
_buildTypeCountsRow(
context,
label: 'Winners',
counts: year.winnerTypeCounts,
),
const SizedBox(height: 8),
_buildTypeCountsRow(
context,
label: 'Total',
counts: year.totalTypeCounts,
),
],
),
);
}
Widget _buildTypeCountsRow(
BuildContext context, {
required String label,
required Map<String, int> counts,
}) {
final theme = Theme.of(context);
final entries = _sortedTypeCounts(counts);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 4),
if (entries.isEmpty)
Text(
'No data',
style: theme.textTheme.bodySmall,
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: entries
.map((entry) => _buildInfoChip(
context,
label: entry.key,
value: _countFormat.format(entry.value),
))
.toList(),
),
],
);
}
Widget _buildSection<T>( Widget _buildSection<T>(
BuildContext context, { BuildContext context, {
required String title, required String title,

View File

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

View File

@@ -4,6 +4,8 @@ enum _TractionMoreAction {
classStats, classStats,
classLeaderboard, classLeaderboard,
adminPending, adminPending,
adminPendingChanges,
adminImport,
} }
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
@@ -14,6 +16,7 @@ class TractionPage extends StatefulWidget {
this.replacementPendingLocoId, this.replacementPendingLocoId,
this.transferFromLabel, this.transferFromLabel,
this.transferFromLocoId, this.transferFromLocoId,
this.transferAllAllocations = false,
this.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
}); });
@@ -23,6 +26,7 @@ class TractionPage extends StatefulWidget {
final int? replacementPendingLocoId; final int? replacementPendingLocoId;
final String? transferFromLabel; final String? transferFromLabel;
final int? transferFromLocoId; final int? transferFromLocoId;
final bool transferAllAllocations;
final ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -38,6 +42,12 @@ class _TractionPageState extends State<TractionPage> {
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId; int? get _transferFromLocoId => widget.transferFromLocoId;
bool get _transferAllAllocations {
if (widget.transferAllAllocations) return true;
final param =
GoRouterState.of(context).uri.queryParameters['transferAll'];
return param?.toLowerCase() == 'true' || param == '1';
}
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
@@ -65,6 +75,7 @@ class _TractionPageState extends State<TractionPage> {
String? _lastQuerySignature; String? _lastQuerySignature;
String? _transferFromLabel; String? _transferFromLabel;
bool _isSearching = false; bool _isSearching = false;
bool _classExporting = false;
@override @override
void initState() { void initState() {
@@ -640,8 +651,11 @@ class _TractionPageState extends State<TractionPage> {
return content; return content;
} }
String get _currentClassLabel =>
(_selectedClass ?? _classController.text).trim();
bool get _hasClassQuery { bool get _hasClassQuery {
return (_selectedClass ?? _classController.text).trim().isNotEmpty; return _currentClassLabel.isNotEmpty;
} }
Widget _buildHeaderActions(BuildContext context, bool isMobile) { Widget _buildHeaderActions(BuildContext context, bool isMobile) {
@@ -659,6 +673,23 @@ class _TractionPageState extends State<TractionPage> {
); );
final hasClassActions = _hasClassQuery; final hasClassActions = _hasClassQuery;
final classLabel = _currentClassLabel;
final exportClassButton = !hasClassActions
? null
: FilledButton.tonalIcon(
onPressed: _classExporting ? null : _exportSelectedClass,
icon: _classExporting
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text(
_classExporting ? 'Exporting...' : 'Export $classLabel',
),
);
final newTractionButton = FilledButton.icon( final newTractionButton = FilledButton.icon(
onPressed: () async { onPressed: () async {
@@ -708,23 +739,59 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
break; break;
case _TractionMoreAction.adminPendingChanges:
final messenger = ScaffoldMessenger.of(context);
try {
await context.push('/traction/changes');
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Unable to open pending changes'),
),
);
}
break;
case _TractionMoreAction.adminImport:
await _showTractionImportSheet();
break;
} }
}, },
itemBuilder: (context) { itemBuilder: (context) {
final items = <PopupMenuEntry<_TractionMoreAction>>[]; final items = <PopupMenuEntry<_TractionMoreAction>>[];
if (hasClassActions) { if (hasClassActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.classStats, value: _TractionMoreAction.classStats,
child: Text('Class stats'), child: Row(
children: [
Icon(
_showClassStatsPanel ? Icons.check : Icons.check_box_outline_blank,
size: 18,
),
const SizedBox(width: 8),
const Text('Class stats'),
],
),
), ),
); );
} }
if (hasClassActions) { if (hasClassActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.classLeaderboard, value: _TractionMoreAction.classLeaderboard,
child: Text('Class leaderboard'), child: Row(
children: [
Icon(
_showClassLeaderboardPanel
? Icons.check
: Icons.check_box_outline_blank,
size: 18,
),
const SizedBox(width: 8),
const Text('Class leaderboard'),
],
),
), ),
); );
} }
@@ -745,6 +812,18 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
); );
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPendingChanges,
child: Text('Pending changes'),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminImport,
child: Text('Import traction'),
),
);
} }
return items; return items;
}, },
@@ -768,12 +847,14 @@ class _TractionPageState extends State<TractionPage> {
final desktopActions = [ final desktopActions = [
refreshButton, refreshButton,
if (exportClassButton != null) exportClassButton,
newTractionButton, newTractionButton,
if (moreButton != null) moreButton, if (moreButton != null) moreButton,
]; ];
final mobileActions = [ final mobileActions = [
if (moreButton != null) moreButton, if (moreButton != null) moreButton,
if (exportClassButton != null) exportClassButton,
newTractionButton, newTractionButton,
refreshButton, refreshButton,
]; ];
@@ -801,6 +882,331 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
Future<void> _exportSelectedClass() async {
if (_classExporting) return;
final classLabel = _currentClassLabel;
if (classLabel.isEmpty) return;
setState(() => _classExporting = true);
final messenger = ScaffoldMessenger.of(context);
try {
final data = context.read<DataService>();
final encodedClass = Uri.encodeComponent(classLabel);
final response = await data.api.getBytes(
'/loco/class/export/$encodedClass',
headers: const {'accept': '*/*'},
);
final safeClassName =
classLabel.replaceAll(RegExp(r'[^a-zA-Z0-9-_]+'), '_');
final fallbackName = 'traction-${safeClassName.isEmpty ? 'class' : safeClassName}.xlsx';
final filename = response.filename ?? fallbackName;
final saveResult = await saveBytes(
Uint8List.fromList(response.bytes),
filename,
mimeType: response.contentType,
);
if (!mounted) return;
if (saveResult.canceled) {
messenger.showSnackBar(
const SnackBar(content: Text('Export canceled.')),
);
} else {
final path = saveResult.path;
messenger.showSnackBar(
SnackBar(
content: Text(
path == null || path.isEmpty
? 'Export started.'
: 'Export saved to $path',
),
),
);
}
} on ApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: ${e.message}')),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
} finally {
if (mounted) setState(() => _classExporting = false);
}
}
Future<void> _showTractionImportSheet() async {
final isElevated = context.read<AuthService>().isElevated;
if (!isElevated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Import is available to admins only.')),
);
return;
}
XFile? selectedFile;
bool uploading = false;
Map<String, dynamic>? importResult;
String? statusMessage;
String? errorMessage;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return StatefulBuilder(
builder: (context, setModalState) {
Future<void> pickFile() async {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'Spreadsheets',
extensions: ['xlsx', 'xls', 'ods', 'odf'],
),
],
);
if (file == null) return;
setModalState(() {
selectedFile = file;
importResult = null;
statusMessage = null;
errorMessage = null;
});
}
Future<void> uploadFile() async {
final file = selectedFile;
if (file == null || uploading) return;
setModalState(() {
uploading = true;
importResult = null;
statusMessage = null;
errorMessage = null;
});
try {
final data = context.read<DataService>();
final bytes = await file.readAsBytes();
final response = await data.api.postMultipartFile(
'/loco/class/import',
bytes: bytes,
filename: file.name,
headers: const {'accept': 'application/json'},
);
if (!mounted) return;
final parsed = response is Map
? Map<String, dynamic>.from(response)
: null;
if (parsed != null) {
final imported = _importCount(parsed['imported']);
final updated = _importCount(parsed['updated']);
final errors = _importErrors(parsed);
final errorNote =
errors.isNotEmpty ? ' (${errors.length} error(s))' : '';
statusMessage =
'Import complete. Imported $imported, updated $updated$errorNote.';
importResult = parsed;
} else {
statusMessage = 'Import complete.';
}
await data.fetchClassList();
await _refreshTraction(preservePosition: true);
} on ApiException catch (e) {
if (!mounted) return;
errorMessage = e.message;
} catch (e) {
if (!mounted) return;
errorMessage = e.toString();
} finally {
if (!mounted) return;
setModalState(() => uploading = false);
}
}
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Import traction data',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Upload a spreadsheet to import traction records.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
selectedFile?.name ?? 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: uploading ? null : pickFile,
icon: const Icon(Icons.upload_file),
label: const Text('Choose file'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed:
selectedFile == null || uploading ? null : uploadFile,
icon: uploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.file_upload),
label: Text(
uploading ? 'Importing...' : 'Upload and import',
),
),
if (statusMessage != null) ...[
const SizedBox(height: 12),
Text(
statusMessage!,
style: theme.textTheme.bodyMedium,
),
],
if (errorMessage != null) ...[
const SizedBox(height: 12),
Text(
errorMessage!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (importResult != null)
_buildImportSummary(context, importResult!),
],
),
),
),
);
},
);
},
);
}
int _importCount(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
List<dynamic> _importErrors(Map<String, dynamic> result) {
final errors = result['errors'];
if (errors == null) return const [];
if (errors is List) return errors;
return [errors];
}
String _stringifyImportError(dynamic err) {
if (err == null) return '';
if (err is String) return err;
try {
return jsonEncode(err);
} catch (_) {
return err.toString();
}
}
Widget _buildImportSummary(
BuildContext context,
Map<String, dynamic> result,
) {
final imported = _importCount(result['imported']);
final updated = _importCount(result['updated']);
final errors = _importErrors(result);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Import summary',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
_buildImportChip(context, 'Imported', imported.toString()),
_buildImportChip(context, 'Updated', updated.toString()),
_buildImportChip(context, 'Errors', errors.length.toString()),
],
),
if (errors.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Errors',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: errors
.map(
(err) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(_stringifyImportError(err)),
),
)
.toList(),
),
],
],
);
}
Widget _buildImportChip(
BuildContext context,
String label,
String value,
) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
);
}
Future<void> _toggleClassStatsPanel() async { Future<void> _toggleClassStatsPanel() async {
if (!_hasClassQuery) return; if (!_hasClassQuery) return;
final targetState = !_showClassStatsPanel; final targetState = !_showClassStatsPanel;
@@ -1275,13 +1681,19 @@ class _TractionPageState extends State<TractionPage> {
context: navContext, context: navContext,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Transfer allocations?'), title: Text(
_transferAllAllocations
? 'Transfer all allocations?'
: 'Transfer allocations?',
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Transfer all allocations from $fromLabel to $toLabel?', _transferAllAllocations
? 'Transfer all user allocations from $fromLabel to $toLabel?'
: 'Transfer all allocations from $fromLabel to $toLabel?',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
@@ -1311,10 +1723,23 @@ class _TractionPageState extends State<TractionPage> {
if (!navContext.mounted) return; if (!navContext.mounted) return;
try { try {
final data = navContext.read<DataService>(); final data = navContext.read<DataService>();
if (_transferAllAllocations) {
await data.transferAllAllocations(
fromLocoId: fromId,
toLocoId: target.id,
);
} else {
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id); await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
}
if (navContext.mounted) { if (navContext.mounted) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Allocations transferred')), SnackBar(
content: Text(
_transferAllAllocations
? 'All allocations transferred'
: 'Allocations transferred',
),
),
); );
} }
await _refreshTraction(preservePosition: true); await _refreshTraction(preservePosition: true);
@@ -1386,7 +1811,9 @@ class _TractionPageState extends State<TractionPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Transferring allocations from $label. Select a loco to transfer to.', _transferAllAllocations
? 'Transferring all allocations from $label. Select a loco to transfer to.'
: 'Transferring allocations from $label. Select a loco to transfer to.',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),

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

@@ -724,10 +724,12 @@ Future<void> showTractionDetails(
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (hasMileageOrTrips)
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
final transferLabel = '${loco.locoClass} ${loco.number}'.trim(); final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push( navContext.push(
Uri( Uri(
path: '/traction', path: '/traction',
@@ -735,12 +737,14 @@ Future<void> showTractionDetails(
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id.toString(), 'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel, 'transferFromLabel': transferLabel,
'transferAll': '0',
}, },
).toString(), ).toString(),
extra: { extra: {
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id, 'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel, 'transferFromLabel': transferLabel,
'transferAll': false,
}, },
); );
}, },
@@ -757,6 +761,34 @@ Future<void> showTractionDetails(
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
children: [ 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( FilledButton.tonal(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: backgroundColor:

View File

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

View File

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

View File

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

View File

@@ -519,6 +519,8 @@ class StatsAbout {
final networkByYear = <int, List<StatsNetworkMileage>>{}; final networkByYear = <int, List<StatsNetworkMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{}; final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{}; final winnersByYear = <int, int>{};
final winnerTypeCountsByYear = <int, Map<String, int>>{};
final totalTypeCountsByYear = <int, Map<String, int>>{};
void addYearMileage(dynamic entry) { void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) { if (entry is Map<String, dynamic>) {
@@ -589,6 +591,16 @@ class StatsAbout {
} }
} }
Map<String, int> parseTypeCounts(dynamic value) {
if (value is Map) {
final entries = value.entries
.map((entry) => MapEntry(entry.key.toString(), _asInt(entry.value)))
.where((entry) => entry.key.isNotEmpty);
return Map<String, int>.fromEntries(entries);
}
return const {};
}
parseYearMap<List<StatsClassMileage>>( parseYearMap<List<StatsClassMileage>>(
json['top_classes'], json['top_classes'],
classByYear, classByYear,
@@ -610,6 +622,19 @@ class StatsAbout {
if (year == null) return; if (year == null) return;
if (value is List) { if (value is List) {
winnersByYear[year] = value.length; winnersByYear[year] = value.length;
} else if (value is Map) {
final mapped = value.map(
(entryKey, entryValue) =>
MapEntry(entryKey.toString(), entryValue),
);
final winners = mapped['winners'];
if (winners is List) {
winnersByYear[year] = winners.length;
}
winnerTypeCountsByYear[year] =
parseTypeCounts(mapped['winner_type_counts']);
totalTypeCountsByYear[year] =
parseTypeCounts(mapped['total_type_counts']);
} }
}); });
} }
@@ -620,6 +645,8 @@ class StatsAbout {
...networkByYear.keys, ...networkByYear.keys,
...stationByYear.keys, ...stationByYear.keys,
...winnersByYear.keys, ...winnersByYear.keys,
...winnerTypeCountsByYear.keys,
...totalTypeCountsByYear.keys,
}..removeWhere((year) => year == 0); }..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{}; final yearMap = <int, StatsYear>{};
@@ -631,6 +658,8 @@ class StatsAbout {
topNetworks: networkByYear[year] ?? const [], topNetworks: networkByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [], topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0, winnerCount: winnersByYear[year] ?? 0,
winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {},
totalTypeCounts: totalTypeCountsByYear[year] ?? const {},
); );
} }
@@ -651,6 +680,8 @@ class StatsYear {
final List<StatsNetworkMileage> topNetworks; final List<StatsNetworkMileage> topNetworks;
final List<StatsStationVisits> topStations; final List<StatsStationVisits> topStations;
final int winnerCount; final int winnerCount;
final Map<String, int> winnerTypeCounts;
final Map<String, int> totalTypeCounts;
StatsYear({ StatsYear({
required this.year, required this.year,
@@ -659,6 +690,8 @@ class StatsYear {
required this.topNetworks, required this.topNetworks,
required this.topStations, required this.topStations,
required this.winnerCount, required this.winnerCount,
required this.winnerTypeCounts,
required this.totalTypeCounts,
}); });
} }
@@ -1779,12 +1812,18 @@ class ClassClearanceProgress {
final int completed; final int completed;
final int total; final int total;
final double percentComplete; final double percentComplete;
final int activeCompleted;
final int activeTotal;
final double activePercent;
ClassClearanceProgress({ ClassClearanceProgress({
required this.className, required this.className,
required this.completed, required this.completed,
required this.total, required this.total,
required this.percentComplete, required this.percentComplete,
required this.activeCompleted,
required this.activeTotal,
required this.activePercent,
}); });
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) { factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
@@ -1802,11 +1841,34 @@ class ClassClearanceProgress {
if (percent == 0 && total > 0) { if (percent == 0 && total > 0) {
percent = (completed / total) * 100; percent = (completed / total) * 100;
} }
final activeCompleted = _asInt(
json['active_completed'] ??
json['active_done'] ??
json['active_count'] ??
json['active_had'] ??
json['had_active'],
);
final activeTotal =
_asInt(json['active_total'] ??
json['active_required'] ??
json['active_goal'] ??
json['total_active']);
double activePercent = _asDouble(
json['active_percent'] ??
json['active_pct'] ??
json['active_completion'],
);
if (activePercent == 0 && activeTotal > 0) {
activePercent = (activeCompleted / activeTotal) * 100;
}
return ClassClearanceProgress( return ClassClearanceProgress(
className: name.isNotEmpty ? name : 'Class', className: name.isNotEmpty ? name : 'Class',
completed: completed, completed: completed,
total: total, total: total,
percentComplete: percent, percentComplete: percent,
activeCompleted: activeCompleted,
activeTotal: activeTotal,
activePercent: activePercent,
); );
} }
} }

View File

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

View File

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

View File

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

View File

@@ -482,6 +482,23 @@ extension DataServiceTraction on DataService {
} }
} }
Future<void> transferAllAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer?transferAll=true', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint(
'Failed to transfer all allocations $fromLocoId -> $toLocoId: $e',
);
rethrow;
}
}
Future<void> adminDeleteLoco({required int locoId}) async { Future<void> adminDeleteLoco({required int locoId}) async {
try { try {
await api.delete('/loco/admin/delete/$locoId'); await api.delete('/loco/admin/delete/$locoId');

View File

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

View File

@@ -21,6 +21,7 @@ import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart'; import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_page.dart';
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart'; import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
@@ -199,6 +200,22 @@ class _MyAppState extends State<MyApp> {
(state.extra is Map (state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString() ? (state.extra as Map)['transferFromLabel']?.toString()
: null); : null);
bool transferAllAllocations = false;
final transferAllParam =
state.uri.queryParameters['transferAll'] ??
(state.extra is Map
? (state.extra as Map)['transferAll']?.toString()
: null);
if (transferAllParam != null) {
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
transferAllParam == '1';
}
if (!transferAllAllocations && state.extra is Map) {
final raw = (state.extra as Map)['transferAll'];
if (raw is bool) {
transferAllAllocations = raw;
}
}
final selectionMode = final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) || (selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null || replacementPendingLocoId != null ||
@@ -214,6 +231,7 @@ class _MyAppState extends State<MyApp> {
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel, transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId, transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
); );
}, },
), ),
@@ -221,6 +239,11 @@ class _MyAppState extends State<MyApp> {
path: '/traction/pending', path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(), builder: (context, state) => const TractionPendingPage(),
), ),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
@@ -238,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra; label = extra;
} }
if (label.trim().isEmpty) label = 'Loco $locoId'; if (label.trim().isEmpty) label = 'Loco $locoId';
return LocoTimelinePage(locoId: locoId, locoLabel: label); bool showPending = false;
if (extra is Map && extra['showPending'] is bool) {
showPending = extra['showPending'] as bool;
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
forceShowPending: showPending,
);
}, },
), ),
GoRoute( GoRoute(
@@ -523,10 +554,7 @@ class _MyHomePageState extends State<MyHomePage> {
) )
.toList(); .toList();
return Scaffold( final logo = Text.rich(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text.rich(
TextSpan( TextSpan(
children: const [ children: const [
TextSpan(text: "Mile"), TextSpan(text: "Mile"),
@@ -542,6 +570,17 @@ class _MyHomePageState extends State<MyHomePage> {
fontFamily: "Tomatoes", 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: [ actions: [
_buildNotificationAction(context, data), _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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "mileograph_flutter") set(BINARY_NAME "Mileograph")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.mileograph_flutter") set(APPLICATION_ID "com.petegregoryy.mileograph_flutter")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,265 @@
import 'package:flutter/foundation.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:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'test_data.dart';
class FakeApiService extends ApiService {
FakeApiService({String baseUrl = 'https://example.com'})
: super(baseUrl: baseUrl);
final Map<String, dynamic> getResponses = {};
final Map<String, dynamic> postResponses = {};
@override
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
return getResponses[endpoint];
}
@override
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) 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 {}
@override
Future<void> fetchTripDetails() async {}
@override
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {}
@override
Future<void> fetchLatestLocoChanges({
int limit = 100,
int offset = 0,
bool append = false,
}) async {}
@override
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,
}) async {}
@override
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
return const [];
}
@override
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 {}
@override
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<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
return TestData.classStats;
}
@override
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
String locoClass, {
bool friends = false,
}) async {
return TestData.classLeaderboard;
}
}
class FakeDistanceUnitService extends DistanceUnitService {
FakeDistanceUnitService({this.unitOverride});
final DistanceUnit? unitOverride;
@override
DistanceUnit get unit => unitOverride ?? super.unit;
@override
String format(
double miles, {
int decimals = 1,
bool includeUnit = true,
}) {
final formatter = DistanceFormatter(unitOverride ?? super.unit);
return formatter.format(miles, decimals: decimals, includeUnit: includeUnit);
}
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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