11 Commits

Author SHA1 Message Date
7feb672e7e fix timeline popover
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m56s
Release / android-build (push) Successful in 16m36s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s
2025-12-22 17:33:33 +00:00
45d543498f Layout changes, fix bugs in new entry page 2025-12-22 17:23:21 +00:00
63b545c7a3 increment version
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m22s
Release / android-build (push) Successful in 17m22s
Release / release-master (push) Successful in 33s
Release / release-dev (push) Successful in 35s
2025-12-17 17:42:54 +00:00
587933fa50 fix navbar freezing fix
Some checks failed
Release / meta (push) Failing after 9s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 17:41:09 +00:00
a9e0cdbe1b re add the mileograph tags
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m43s
Release / android-build (push) Successful in 20m14s
Release / release-master (push) Successful in 50s
Release / release-dev (push) Successful in 53s
2025-12-17 16:39:27 +00:00
334d6e3e18 major refactor
Some checks failed
Release / meta (push) Successful in 9s
Release / android-build (push) Failing after 4m3s
Release / linux-build (push) Successful in 5m38s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 16:32:53 +00:00
1239a9dc85 add repo review to gitignore
Some checks failed
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m29s
Release / android-build (push) Failing after 38m59s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-17 15:19:09 +00:00
ceb453cd59 increment version for release
Some checks failed
Release / meta (push) Successful in 7s
Release / android-build (push) Failing after 32m18s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
2025-12-17 14:58:11 +00:00
e9a9e66e39 add loco legs panel
Some checks failed
Release / meta (push) Failing after 9s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 14:42:31 +00:00
fa9773bcd1 add timeline edit/delete
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 17m20s
Release / release-dev (push) Successful in 37s
Release / release-master (push) Successful in 35s
2025-12-17 12:17:41 +00:00
80be797322 major refactor
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 15m50s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2025-12-16 16:49:39 +00:00
52 changed files with 6242 additions and 4666 deletions

View File

@@ -29,8 +29,40 @@ jobs:
run: | run: |
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*} BASE_VERSION=${RAW_VERSION%%+*}
TAG="v${BASE_VERSION}"
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAG="v${BASE_VERSION}-dev"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Fail if release already exists
env:
TAG: ${{ steps.meta.outputs.release_tag }}
run: |
set -euo pipefail
if ! command -v curl >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y curl ca-certificates
fi
URL="${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}"
CODE="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "$URL" || true)"
if [ "$CODE" = "200" ]; then
echo "Release already exists for tag ${TAG}; refusing to re-release."
exit 1
fi
if [ "$CODE" != "404" ]; then
echo "Unexpected response checking existing release (${CODE}) at ${URL}"
exit 1
fi
android-build: android-build:
runs-on: runs-on:
@@ -235,7 +267,7 @@ jobs:
id: bundle id: bundle
run: | run: |
BASE="${{ needs.meta.outputs.base_version }}" BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}-dev.${{ github.run_number }}" TAG="${{ needs.meta.outputs.release_tag }}"
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk" mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
@@ -247,7 +279,7 @@ jobs:
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
tag: ${{ steps.bundle.outputs.tag }} tag: ${{ steps.bundle.outputs.tag }}
name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }} name: ${{ steps.bundle.outputs.tag }}
prerelease: true prerelease: true
commit: ${{ github.sha }} commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ app.*.map.json
/android/app/release /android/app/release
api_return_examples.txt api_return_examples.txt
REPO_REVIEW.md

View File

@@ -1 +1,3 @@
{} {
"cmake.ignoreCMakeListsMissing": true
}

View File

@@ -45,7 +45,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.mileograph_flutter" applicationId = "com.petegregoryy.mileograph_flutter"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

28
lib/app.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<ApiService>(
create: (_) => ApiService(baseUrl: 'http://localhost:8000/api/v1'),
),
ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()),
),
ChangeNotifierProvider<DataService>(
create: (context) => DataService(api: context.read<ApiService>()),
),
],
child: const MyApp(),
);
}
}

View File

@@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import './routeSummaryWidget.dart'; import './route_summary_widget.dart';
class StationAutocomplete extends StatefulWidget { class StationAutocomplete extends StatefulWidget {
const StationAutocomplete({ const StationAutocomplete({
@@ -24,9 +25,6 @@ class StationAutocomplete extends StatefulWidget {
class _StationAutocompleteState extends State<StationAutocomplete> { class _StationAutocompleteState extends State<StationAutocomplete> {
late final TextEditingController _controller; late final TextEditingController _controller;
// Simulated list of over 10,000 stations
final List<String> stations = List.generate(10000, (i) => 'Station $i');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -49,15 +47,7 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
if (textEditingValue.text.isEmpty) { if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty(); return const Iterable<String>.empty();
} }
final query = textEditingValue.text.toLowerCase(); return _findTopMatches(textEditingValue.text);
final matches = widget.allStations
.map((s) => s.name)
.where((name) => name.toLowerCase().contains(query))
.toList();
matches.sort((a, b) => a.length.compareTo(b.length));
return matches.take(10);
}, },
onSelected: (String selection) { onSelected: (String selection) {
_controller.text = selection; _controller.text = selection;
@@ -72,19 +62,12 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
focusNode: focusNode, focusNode: focusNode,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) { onSubmitted: (_) {
final query = textEditingController.text.toLowerCase(); final matches = _findTopMatches(textEditingController.text);
final matches = widget.allStations final firstMatch = matches.isEmpty ? null : matches.first;
.map((s) => s.name) if (firstMatch == null) return;
.where((name) => name.toLowerCase().contains(query)) _controller.text = firstMatch;
.toList(); widget.onChanged(firstMatch);
focusNode.unfocus(); // optionally close keyboard
if (matches.isNotEmpty) {
matches.sort((a, b) => a.length.compareTo(b.length));
final firstMatch = matches.first;
_controller.text = firstMatch;
widget.onChanged(firstMatch);
focusNode.unfocus(); // optionally close keyboard
}
}, },
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Select station', labelText: 'Select station',
@@ -94,6 +77,42 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
}, },
); );
} }
Iterable<String> _findTopMatches(String rawQuery) {
final query = rawQuery.trim().toLowerCase();
if (query.isEmpty) return const <String>[];
// Keep a bounded, sorted list (by shortest name, then alpha) without
// sorting the entire match set.
final best = <String>[];
for (final station in widget.allStations) {
final name = station.name;
if (name.isEmpty) continue;
if (!name.toLowerCase().contains(query)) continue;
_insertCandidate(best, name, max: 10);
}
return best;
}
void _insertCandidate(List<String> best, String candidate, {required int max}) {
final existingIndex = best.indexOf(candidate);
if (existingIndex >= 0) return;
int insertAt = 0;
while (insertAt < best.length &&
_candidateCompare(best[insertAt], candidate) <= 0) {
insertAt++;
}
best.insert(insertAt, candidate);
if (best.length > max) best.removeLast();
}
int _candidateCompare(String a, String b) {
final byLength = a.length.compareTo(b.length);
if (byLength != 0) return byLength;
return a.compareTo(b);
}
} }
class RouteCalculator extends StatefulWidget { class RouteCalculator extends StatefulWidget {
@@ -119,8 +138,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
String? _errorMessage; String? _errorMessage;
bool _showDetails = false;
bool _fetched = false; bool _fetched = false;
@override @override
@@ -147,20 +164,28 @@ class _RouteCalculatorState extends State<RouteCalculator> {
_routeResult = null; _routeResult = null;
}); });
final api = context.read<ApiService>(); // context is valid here final api = context.read<ApiService>(); // context is valid here
final res = await api.post('/route/distance2', { try {
'route': stations.where((s) => s.trim().isNotEmpty).toList(), final res = await api.post('/route/distance2', {
}); 'route': stations.where((s) => s.trim().isNotEmpty).toList(),
});
if (res['error'] == false) { if (res is Map && res['error'] == false) {
setState(() { setState(() {
_routeResult = RouteResult.fromJson(res); _routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
}); });
final distance = (_routeResult?.distance ?? 0); final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance); widget.onDistanceComputed?.call(distance);
} else { } else if (res is Map && res['error_obj'] is List && res['error_obj'].isNotEmpty) {
setState(() { setState(() {
_errorMessage = RouteError.fromJson(res["error_obj"][0]).msg; _errorMessage = RouteError.fromJson(
}); Map<String, dynamic>.from(res['error_obj'][0] as Map),
).msg;
});
} else {
setState(() => _errorMessage = 'Failed to calculate route.');
}
} catch (e) {
setState(() => _errorMessage = 'Failed to calculate route: $e');
} }
} }
@@ -188,13 +213,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
if (_showDetails && _routeResult != null) {
return RouteDetailsView(
route: _routeResult!.calculatedRoute,
costs: _routeResult!.costs,
onBack: () => setState(() => _showDetails = false),
);
}
return Column( return Column(
children: [ children: [
Expanded( Expanded(
@@ -263,7 +281,11 @@ class _RouteCalculatorState extends State<RouteCalculator> {
else if (_routeResult != null) ...[ else if (_routeResult != null) ...[
RouteSummaryWidget( RouteSummaryWidget(
distance: _routeResult!.distance, distance: _routeResult!.distance,
onDetailsPressed: () => setState(() => _showDetails = true), onDetailsPressed: () {
final result = _routeResult;
if (result == null) return;
context.push('/calculator/details', extra: result);
},
), ),
if (widget.onApplyRoute != null) if (widget.onApplyRoute != null)
Padding( Padding(

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LatestLocoChangesPanel extends StatefulWidget {
const LatestLocoChangesPanel({super.key});
@override
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
}
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
late final ScrollController _controller;
@override
void initState() {
super.initState();
_controller = ScrollController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final changes = data.latestLocoChanges;
final isLoading = data.isLatestLocoChangesLoading;
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Latest loco changes',
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (isLoading && changes.isEmpty)
const Padding(
padding: EdgeInsets.all(12.0),
child: Center(child: CircularProgressIndicator()),
)
else if (changes.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'No recent loco changes yet.',
style: textTheme.bodyMedium,
),
)
else
SizedBox(
height: 260,
child: Scrollbar(
controller: _controller,
child: ListView.separated(
controller: _controller,
itemCount: changes.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final change = changes[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(
change.locoLabel,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${change.changeLabel}: ${change.valueLabel}'),
Text(
change.approvedDateLabel,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
],
),
trailing: change.approvedBy.isEmpty
? null
: Text(
change.approvedBy,
style: textTheme.labelSmall,
),
);
},
),
),
),
],
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -16,23 +16,24 @@ class LeaderboardPanel extends StatelessWidget {
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
return Padding( return Card(
padding: const EdgeInsets.all(10.0), clipBehavior: Clip.antiAlias,
child: Card( child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"Leaderboard", "Leaderboard",
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
), ),
), ),
const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(8.0),
child: Text('No leaderboard data yet'), child: Text('No leaderboard data yet'),
) )
else else
@@ -46,7 +47,7 @@ class LeaderboardPanel extends StatelessWidget {
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 0, vertical: 8), horizontal: 0, vertical: 8),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -17,23 +17,24 @@ class TopTractionPanel extends StatelessWidget {
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
return Padding( return Card(
padding: const EdgeInsets.all(10.0), clipBehavior: Clip.antiAlias,
child: Card( child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"Top Traction", "Top Traction",
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
), ),
), ),
const SizedBox(height: 8),
if (locos.isEmpty) if (locos.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(8.0),
child: Text('No traction data yet'), child: Text('No traction data yet'),
) )
else else
@@ -47,7 +48,7 @@ class TopTractionPanel extends StatelessWidget {
margin: margin:
const EdgeInsets.symmetric(horizontal: 0, vertical: 8), const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [

View File

@@ -0,0 +1,215 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
class LegCard extends StatelessWidget {
const LegCard({
super.key,
required this.leg,
this.showEditButton = true,
this.showDate = true,
});
final Leg leg;
final bool showEditButton;
final bool showDate;
@override
Widget build(BuildContext context) {
final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme;
return Card(
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDate) Text(_formatDateTime(leg.beginTime)),
if (leg.headcode.isNotEmpty)
Text(
'Headcode: ${leg.headcode}',
style: textTheme.labelSmall,
),
if (leg.network.isNotEmpty)
Text(
leg.network,
style: textTheme.labelSmall,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (leg.tripId != 0) ...[
const SizedBox(height: 2),
Text(
'Trip #${leg.tripId}',
style: textTheme.labelSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
if (showEditButton) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Edit entry',
icon: const Icon(Icons.edit),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => context.push('/legs/edit/${leg.id}'),
),
],
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leg.notes.isNotEmpty) ...[
Text('Notes', style: textTheme.titleSmall),
const SizedBox(height: 4),
Text(leg.notes),
const SizedBox(height: 12),
],
if (leg.locos.isNotEmpty) ...[
Text('Locos', style: textTheme.titleSmall),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 8,
children: _buildLocoChips(context, leg),
),
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
_buildRouteList(routeSegments),
],
],
),
),
],
),
);
}
String _formatDate(DateTime? date) {
if (date == null) return '';
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
String _formatDateTime(DateTime date) {
final dateStr = _formatDate(date);
final timeStr =
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return '$dateStr · $timeStr';
}
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
return leg.locos
.map(
(loco) {
final powering = loco.powering == true;
final iconColor =
powering ? theme.colorScheme.primary : theme.disabledColor;
final labelStyle = powering
? null
: textTheme.bodyMedium?.copyWith(color: theme.disabledColor);
final background = powering
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceVariant;
return Chip(
label: Text(
'${loco.locoClass} ${loco.number}',
style: labelStyle,
),
avatar: Icon(
Icons.directions_railway,
size: 16,
color: iconColor,
),
backgroundColor: background,
);
},
)
.toList();
}
Widget _buildRouteList(List<String> segments) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: segments
.map(
(segment) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
const Icon(Icons.circle, size: 10),
const SizedBox(width: 8),
Expanded(child: Text(segment)),
],
),
),
)
.toList(),
);
}
List<String> _parseRouteSegments(String route) {
final trimmed = route.trim();
if (trimmed.isEmpty) return [];
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
final replaced = trimmed.replaceAll("'", '"');
final decoded = jsonDecode(replaced);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
}
if (trimmed.contains('->')) {
return trimmed
.split('->')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (trimmed.contains(',')) {
return trimmed
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [trimmed];
}
}

View File

@@ -151,6 +151,13 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
bool _loggingIn = false; bool _loggingIn = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> login() async { Future<void> login() async {
final username = _usernameController.text; final username = _usernameController.text;
final password = _passwordController.text; final password = _passwordController.text;

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/calculator/route_summary_widget.dart';
import 'package:mileograph_flutter/objects/objects.dart';
class CalculatorDetailsPage extends StatelessWidget {
const CalculatorDetailsPage({
super.key,
required this.result,
});
final Object? result;
@override
Widget build(BuildContext context) {
final parsed = result is RouteResult ? result as RouteResult : null;
if (parsed == null) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
),
const SizedBox(height: 12),
const Text(
'No route details available.',
),
],
),
);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: RouteDetailsView(
route: parsed.calculatedRoute,
costs: parsed.costs,
onBack: () => context.pop(),
),
);
}
}

View File

@@ -1,10 +1,11 @@
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/dashboard/leaderboardPanel.dart'; import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.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/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Dashboard extends StatefulWidget { class Dashboard extends StatefulWidget {
@@ -32,6 +33,7 @@ class _DashboardState extends State<Dashboard> {
data.fetchOnThisDay(), data.fetchOnThisDay(),
data.fetchTripDetails(), data.fetchTripDetails(),
data.fetchHadTraction(), data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]); ]);
}, },
child: LayoutBuilder( child: LayoutBuilder(
@@ -41,7 +43,7 @@ class _DashboardState extends State<Dashboard> {
context, context,
totalMileage: stats?.totalMileage ?? 0, totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(), currentYearMileage: data.getMileageForCurrentYear(),
trips: data.trips.length, legCount: stats?.legCount ?? data.trips.length,
); );
return Stack( return Stack(
children: [ children: [
@@ -78,7 +80,7 @@ class _DashboardState extends State<Dashboard> {
child: Container( child: Container(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.surface.withOpacity(0.7), ).colorScheme.surface.withValues(alpha: 0.7),
child: const Center( child: const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -105,7 +107,7 @@ class _DashboardState extends State<Dashboard> {
bool loading, bool loading,
) { ) {
final greetingName = final greetingName =
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there'; stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -138,7 +140,7 @@ class _DashboardState extends State<Dashboard> {
BuildContext context, { BuildContext context, {
required double totalMileage, required double totalMileage,
required double currentYearMileage, required double currentYearMileage,
required int trips, required int legCount,
}) { }) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
Widget metricCard(String label, String value) { Widget metricCard(String label, String value) {
@@ -154,7 +156,7 @@ class _DashboardState extends State<Dashboard> {
label.toUpperCase(), label.toUpperCase(),
style: textTheme.labelSmall?.copyWith( style: textTheme.labelSmall?.copyWith(
letterSpacing: 0.7, letterSpacing: 0.7,
color: textTheme.bodySmall?.color?.withOpacity(0.7), color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -173,7 +175,7 @@ class _DashboardState extends State<Dashboard> {
return [ return [
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'), metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'), metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
metricCard('Trips logged', trips.toString()), metricCard('Entries logged', legCount.toString()),
]; ];
} }
@@ -211,8 +213,6 @@ class _DashboardState extends State<Dashboard> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildQuickCalcCard(context),
const SizedBox(height: 12),
_buildTripsCard(context, data), _buildTripsCard(context, data),
], ],
); );
@@ -220,10 +220,13 @@ class _DashboardState extends State<Dashboard> {
Widget _buildSidebar(BuildContext context, DataService data) { Widget _buildSidebar(BuildContext context, DataService data) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TopTractionPanel(), const TopTractionPanel(),
const SizedBox(height: 12), const SizedBox(height: 12),
LeaderboardPanel(), const LeaderboardPanel(),
const SizedBox(height: 12),
const LatestLocoChangesPanel(),
], ],
); );
} }
@@ -313,22 +316,6 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildQuickCalcCard(BuildContext context) {
return _buildCard(
context,
title: 'Quick mileage calculator',
action: TextButton.icon(
onPressed: () => context.push('/calculator'),
icon: const Icon(Icons.open_in_new),
label: const Text('Open calculator'),
),
child: Text(
'Jump into the route calculator to quickly total a journey before saving it.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
Widget _buildTripsCard(BuildContext context, DataService data) { Widget _buildTripsCard(BuildContext context, DataService data) {
final tripsUnsorted = data.trips; final tripsUnsorted = data.trips;
List trips = []; List trips = [];
@@ -353,7 +340,6 @@ class _DashboardState extends State<Dashboard> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(trip.tripName), title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
trailing: const Icon(Icons.chevron_right),
); );
}).toList(), }).toList(),
), ),

View File

@@ -1,9 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LegsPage extends StatefulWidget { class LegsPage extends StatefulWidget {
@@ -214,7 +212,7 @@ class _LegsPageState extends State<LegsPage> {
else else
Column( Column(
children: [ children: [
...legs.map((leg) => _buildLegCard(context, leg)), ..._buildLegsWithDividers(context, legs),
const SizedBox(height: 8), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.legsHasMore || data.isLegsLoading)
Align( Align(
@@ -241,164 +239,60 @@ class _LegsPageState extends State<LegsPage> {
); );
} }
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) {
final widgets = <Widget>[];
String? currentDate;
double dayMileage = 0;
final dayLegs = <Leg>[];
void flushDay() {
if (currentDate == null) return;
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Expanded(
child: Text(
currentDate!,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
'${dayMileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map((leg) => LegCard(leg: leg, showDate: false)),
);
dayLegs.clear();
}
for (final leg in legs) {
final dateStr = _formatDate(leg.beginTime) ?? '';
if (currentDate != null && dateStr != currentDate) {
flushDay();
dayMileage = 0;
}
currentDate = dateStr;
dayLegs.add(leg);
dayMileage += leg.mileage;
}
flushDay();
return widgets;
}
String? _formatDate(DateTime? date) { String? _formatDate(DateTime? date) {
if (date == null) return null; if (date == null) return null;
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} }
String _formatDateTime(DateTime date) {
final dateStr = _formatDate(date) ?? '';
final timeStr =
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return '$dateStr · $timeStr';
}
Widget _buildLegCard(BuildContext context, Leg leg) {
final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme;
return Card(
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_formatDateTime(leg.beginTime)),
if (leg.headcode.isNotEmpty)
Text(
'Headcode: ${leg.headcode}',
style: textTheme.labelSmall,
),
if (leg.network.isNotEmpty)
Text(
leg.network,
style: textTheme.labelSmall,
),
],
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: 'Edit entry',
icon: const Icon(Icons.edit),
onPressed: () => context.push('/legs/edit/${leg.id}'),
),
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
style:
textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
if (leg.tripId != 0)
Text(
'Trip #${leg.tripId}',
style: textTheme.labelSmall,
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leg.notes.isNotEmpty) ...[
Text('Notes', style: textTheme.titleSmall),
const SizedBox(height: 4),
Text(leg.notes),
const SizedBox(height: 12),
],
if (leg.locos.isNotEmpty) ...[
Text('Locos', style: textTheme.titleSmall),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 8,
children: _buildLocoChips(context, leg),
),
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
_buildRouteList(routeSegments),
],
],
),
),
],
),
);
}
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
final theme = Theme.of(context);
return leg.locos
.map(
(loco) => Chip(
label: Text('${loco.locoClass} ${loco.number}'),
avatar: const Icon(Icons.directions_railway, size: 16),
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
)
.toList();
}
Widget _buildRouteList(List<String> segments) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: segments
.map(
(segment) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
const Icon(Icons.circle, size: 10),
const SizedBox(width: 8),
Expanded(child: Text(segment)),
],
),
),
)
.toList(),
);
}
List<String> _parseRouteSegments(String route) {
final trimmed = route.trim();
if (trimmed.isEmpty) return [];
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {
// ignore and try alternative parsing
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
final replaced = trimmed.replaceAll("'", '"');
final decoded = jsonDecode(replaced);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
}
if (trimmed.contains('->')) {
return trimmed
.split('->')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (trimmed.contains(',')) {
return trimmed
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [trimmed];
}
} }

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LocoLegsPage extends StatefulWidget {
const LocoLegsPage({
super.key,
required this.locoId,
required this.locoLabel,
});
final int locoId;
final String locoLabel;
@override
State<LocoLegsPage> createState() => _LocoLegsPageState();
}
class _LocoLegsPageState extends State<LocoLegsPage> {
bool _includeNonPowering = false;
late Future<List<Leg>> _future;
@override
void initState() {
super.initState();
_future = _fetch();
}
Future<List<Leg>> _fetch() {
return context.read<DataService>().fetchLegsForLoco(
widget.locoId,
includeNonPowering: _includeNonPowering,
);
}
Future<void> _refresh() async {
final items = await _fetch();
if (!mounted) return;
setState(() {
_future = Future.value(items);
});
}
@override
Widget build(BuildContext context) {
final titleLabel =
widget.locoLabel.trim().isEmpty ? 'Loco ${widget.locoId}' : widget.locoLabel;
return Scaffold(
appBar: AppBar(
title: Text('Legs · $titleLabel'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Card(
child: SwitchListTile(
title: const Text('Include non-powering (dead-in-tow)'),
subtitle: const Text('Off by default'),
value: _includeNonPowering,
onChanged: (val) {
setState(() {
_includeNonPowering = val;
_future = _fetch();
});
},
),
),
),
Expanded(
child: FutureBuilder<List<Leg>>(
future: _future,
builder: (context, snapshot) {
final items = snapshot.data ?? const <Leg>[];
final isLoading =
snapshot.connectionState == ConnectionState.waiting;
if (isLoading && items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (!isLoading && items.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('No legs found for this loco.'),
),
);
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) => LegCard(leg: items[index]),
),
);
},
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,331 @@
part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
class _EventEditor extends StatelessWidget {
const _EventEditor({
required this.eventFields,
required this.drafts,
required this.onAddEvent,
required this.onChange,
required this.onSave,
required this.onRemoveDraft,
required this.isSaving,
required this.canSave,
});
final List<EventField> eventFields;
final List<_EventDraft> drafts;
final VoidCallback onAddEvent;
final VoidCallback onChange;
final Future<void> Function() onSave;
final void Function(int index) onRemoveDraft;
final bool isSaving;
final bool canSave;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Add events',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
Row(
children: [
OutlinedButton.icon(
onPressed: onAddEvent,
icon: const Icon(Icons.add),
label: const Text('New event'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: (!canSave || isSaving) ? null : onSave,
icon: isSaving
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(isSaving ? 'Saving...' : 'Save all'),
),
],
),
],
),
const SizedBox(height: 12),
if (drafts.isEmpty)
const Text('No events yet. Add one to propose new values.')
else
...drafts.asMap().entries.map(
(entry) {
final idx = entry.key;
final draft = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Event ${idx + 1}',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
IconButton(
tooltip: 'Remove',
onPressed: () => onRemoveDraft(idx),
icon: const Icon(Icons.delete_outline),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: draft.dateController,
onChanged: (_) => onChange(),
decoration: const InputDecoration(
labelText: 'Date (YYYY-MM-DD, MM/DD can be XX)',
border: OutlineInputBorder(),
),
),
),
IconButton(
tooltip: 'Pick date',
onPressed: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: draft.date ?? now,
firstDate: DateTime(1900),
lastDate: DateTime(now.year + 10),
);
if (picked != null) {
draft.date = picked;
draft.dateController.text =
DateFormat('yyyy-MM-dd').format(picked);
onChange();
}
},
icon: const Icon(Icons.calendar_month),
),
],
),
const SizedBox(height: 8),
_FieldList(
draft: draft,
eventFields: eventFields,
onChange: onChange,
),
const SizedBox(height: 12),
TextField(
controller: draft.detailsController,
onChanged: (val) {
draft.details = val;
onChange();
},
decoration: const InputDecoration(
labelText: 'Commit message / details',
border: OutlineInputBorder(),
),
),
],
),
),
),
);
},
),
],
);
}
}
class _FieldList extends StatelessWidget {
const _FieldList({
required this.draft,
required this.eventFields,
required this.onChange,
});
final _EventDraft draft;
final List<EventField> eventFields;
final VoidCallback onChange;
@override
Widget build(BuildContext context) {
final usedNames = draft.fields.map((f) => f.field.name).toSet();
final availableFields =
eventFields.where((f) => !usedNames.contains(f.name)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Fields',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
DropdownButton<EventField>(
hint: const Text('Add field'),
value: null,
onChanged: (field) {
if (field == null) return;
draft.fields.add(_FieldEntry(field: field));
onChange();
},
items: availableFields
.map(
(f) => DropdownMenuItem(
value: f,
child: Text(f.display),
),
)
.toList(),
),
],
),
const SizedBox(height: 8),
if (draft.fields.isEmpty)
const Text('No fields added yet.')
else
...draft.fields.asMap().entries.map(
(entry) {
final idx = entry.key;
final field = entry.value;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
field.field.display,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
_FieldInput(
field: field.field,
value: field.value,
onChanged: (val) {
field.value = val;
onChange();
},
),
],
),
),
IconButton(
onPressed: () {
draft.fields.removeAt(idx);
onChange();
},
icon: const Icon(Icons.close),
),
],
),
);
},
),
],
);
}
}
class _FieldInput extends StatelessWidget {
const _FieldInput({
required this.field,
required this.value,
required this.onChanged,
});
final EventField field;
final dynamic value;
final ValueChanged<dynamic> onChanged;
@override
Widget build(BuildContext context) {
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!;
return DropdownButtonFormField<String>(
value: value is String && options.contains(value) ? value : null,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: options
.map((v) => DropdownMenuItem<String>(value: v, child: Text(v)))
.toList(),
onChanged: (val) => onChanged(val),
hint: const Text('Select value'),
);
}
final type = field.type?.toLowerCase();
if (type == 'bool' || type == 'boolean') {
final bool? current =
value is bool ? value : (value is String ? value == 'true' : null);
return DropdownButtonFormField<bool>(
value: current,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: true, child: Text('Yes')),
DropdownMenuItem(value: false, child: Text('No')),
],
onChanged: (val) => onChanged(val),
hint: const Text('Select'),
);
}
final isNumber = type == 'int' || type == 'integer';
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
if (isNumber) {
final parsed = int.tryParse(val);
onChanged(parsed);
} else {
onChanged(val);
}
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
),
keyboardType: isNumber ? TextInputType.number : TextInputType.text,
);
}
}
class _EventDraft {
DateTime? date;
String details = '';
final TextEditingController detailsController = TextEditingController();
final TextEditingController dateController = TextEditingController();
final List<_FieldEntry> fields = [];
_EventDraft();
}
class _FieldEntry {
final EventField field;
dynamic value;
_FieldEntry({required this.field});
}

View File

@@ -0,0 +1,788 @@
part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({
required this.entries,
this.onEditEntry,
this.onDeleteEntry,
});
final List<LocoAttrVersion> entries;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
@override
State<_TimelineGrid> createState() => _TimelineGridState();
}
class _TimelineGridState extends State<_TimelineGrid> {
final ScrollController _horizontalController = ScrollController();
final ScrollController _rightVerticalController = ScrollController();
final ScrollController _leftVerticalController = ScrollController();
bool _isSyncingScroll = false;
double _scrollOffset = 0;
@override
void initState() {
super.initState();
_rightVerticalController.addListener(_syncVerticalScroll);
_horizontalController.addListener(_onHorizontalScroll);
}
@override
void dispose() {
_rightVerticalController.removeListener(_syncVerticalScroll);
_horizontalController.removeListener(_onHorizontalScroll);
_horizontalController.dispose();
_rightVerticalController.dispose();
_leftVerticalController.dispose();
super.dispose();
}
void _syncVerticalScroll() {
if (_isSyncingScroll) return;
if (!_leftVerticalController.hasClients ||
!_rightVerticalController.hasClients) {
return;
}
_isSyncingScroll = true;
_leftVerticalController.jumpTo(
_rightVerticalController.offset.clamp(
0.0,
_leftVerticalController.position.maxScrollExtent,
),
);
_isSyncingScroll = false;
}
void _onHorizontalScroll() {
if (!mounted) return;
setState(() {
_scrollOffset = _horizontalController.offset;
});
}
@override
Widget build(BuildContext context) {
final filteredEntries = widget.entries.where((e) {
final code = e.attrCode.toLowerCase();
return !{
'operational',
'gettable',
'build_prec',
'build_year',
'build_month',
'build_day',
}.contains(code);
}).toList();
final model = _TimelineModel.fromEntries(filteredEntries);
final axisSegments = model.axisSegments;
const labelWidth = 110.0;
const rowHeight = 52.0;
const double axisHeight = 48;
final rows = model.attrRows.entries.toList();
final totalRowsHeight = rows.length * rowHeight;
final axisWidth = math.max(model.axisTotalWidth, 120.0);
final double viewHeight = totalRowsHeight + axisHeight + 8;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: viewHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: labelWidth,
child: Column(
children: [
SizedBox(
height: axisHeight,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Attribute',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
Expanded(
child: Scrollbar(
controller: _leftVerticalController,
thumbVisibility: true,
child: ListView.builder(
controller: _leftVerticalController,
physics: const NeverScrollableScrollPhysics(),
itemExtent: rowHeight,
itemCount: rows.length,
itemBuilder: (_, index) {
final label = _formatAttrLabel(rows[index].key);
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
border: Border(
bottom: BorderSide(
color:
Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: Text(
label,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.w700),
),
);
},
),
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Scrollbar(
controller: _horizontalController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _horizontalController,
scrollDirection: Axis.horizontal,
child: SizedBox(
width: axisWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AxisRow(
segments: axisSegments,
totalWidth: axisWidth,
endLabel: model.endLabel,
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
controller: _rightVerticalController,
itemExtent: rowHeight,
itemCount: rows.length,
itemBuilder: (_, index) {
final blocks = rows[index].value;
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 2.0),
child: _AttrRow(
rowHeight: rowHeight,
blocks: blocks,
model: model,
scrollOffset: _scrollOffset,
viewportWidth: axisWidth,
onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry,
),
);
},
),
),
],
),
),
),
),
),
],
),
),
],
);
}
}
class _AxisRow extends StatelessWidget {
const _AxisRow({
required this.segments,
required this.endLabel,
required this.totalWidth,
});
final List<_AxisSegment> segments;
final String endLabel;
final double totalWidth;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const double axisHeight = 48;
return SizedBox(
width: totalWidth,
height: axisHeight,
child: Stack(
children: [
for (int i = 0; i < segments.length; i++) ...[
Positioned(
left: segments[i].offset,
width: segments[i].width,
top: 0,
bottom: 0,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
segments[i].label,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: theme.textTheme.labelSmall,
),
),
),
],
Positioned(
right: 0,
top: 0,
bottom: 0,
child: Align(
alignment: Alignment.centerRight,
child: Text(
endLabel,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: theme.textTheme.labelSmall,
),
),
),
],
),
);
}
}
class _AttrRow extends StatelessWidget {
const _AttrRow({
required this.rowHeight,
required this.blocks,
required this.model,
required this.scrollOffset,
required this.viewportWidth,
this.onEditEntry,
this.onDeleteEntry,
});
final double rowHeight;
final List<_ValueBlock> blocks;
final _TimelineModel model;
final double scrollOffset;
final double viewportWidth;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
@override
Widget build(BuildContext context) {
final width = math.max(model.axisTotalWidth, 120.0);
final activeBlock = _activeBlock(blocks, scrollOffset);
final double stickyWidth = activeBlock == null
? 0
: (activeBlock.right - scrollOffset).clamp(20.0, viewportWidth);
return SizedBox(
width: width,
height: rowHeight,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
for (final block in blocks)
Positioned(
left: block.left,
width: block.width,
top: 0,
bottom: 0,
child: _ValueBlockMenu(
block: block,
onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry,
),
),
if (activeBlock != null)
Positioned(
left: scrollOffset,
width: stickyWidth,
top: 0,
bottom: 0,
child: IgnorePointer(
child: ClipRect(
child: _ValueBlockView(
block: activeBlock.copyWith(
left: scrollOffset,
width: stickyWidth,
),
clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
),
),
),
),
],
),
);
}
_ValueBlock? _activeBlock(List<_ValueBlock> blocks, double offset) {
for (final block in blocks) {
if (offset >= block.left && offset < block.right) return block;
}
return null;
}
}
class _ValueBlockView extends StatelessWidget {
const _ValueBlockView({
required this.block,
this.clipLeftEdge = false,
});
final _ValueBlock block;
final bool clipLeftEdge;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = block.cell.color.withValues(alpha: 0.9);
final textColor = ThemeData.estimateBrightnessForColor(color) ==
Brightness.dark
? Colors.white
: Colors.black87;
final radius = BorderRadius.only(
topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
topRight: const Radius.circular(12),
bottomRight: const Radius.circular(12),
);
return ClipRRect(
borderRadius: radius,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: block.cell.value.isEmpty
? theme.colorScheme.surfaceContainerHighest
: color,
borderRadius: BorderRadius.zero,
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: block.cell.value.isEmpty
? const SizedBox.shrink()
: FittedBox(
alignment: Alignment.topLeft,
fit: BoxFit.scaleDown,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 1, minHeight: 1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
block.cell.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: textColor,
) ??
TextStyle(
fontWeight: FontWeight.w700,
color: textColor,
),
),
const SizedBox(height: 4),
Text(
block.cell.rangeLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: textColor.withValues(alpha: 0.9),
) ??
TextStyle(color: textColor.withValues(alpha: 0.9)),
),
],
),
),
),
),
);
}
}
enum _TimelineBlockAction { edit, delete }
class _ValueBlockMenu extends StatelessWidget {
const _ValueBlockMenu({
required this.block,
this.onEditEntry,
this.onDeleteEntry,
});
final _ValueBlock block;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
bool get _hasActions => onEditEntry != null || onDeleteEntry != null;
@override
Widget build(BuildContext context) {
if (!_hasActions || block.entry == null) {
return _ValueBlockView(block: block);
}
Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context);
final renderBox = overlay?.context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
// Translate from global screen coordinates into the overlay's local space
// so the menu appears where the gesture happened.
final localPosition = renderBox.globalToLocal(globalPosition);
final position = RelativeRect.fromRect(
Rect.fromLTWH(localPosition.dx, localPosition.dy, 1, 1),
Offset.zero & renderBox.size,
);
final action = await showMenu<_TimelineBlockAction>(
context: context,
position: position,
items: [
if (onEditEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.edit,
child: Text('Edit'),
),
if (onDeleteEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.delete,
child: Text('Delete'),
),
],
);
final entry = block.entry;
if (action == null || entry == null) return;
switch (action) {
case _TimelineBlockAction.edit:
onEditEntry?.call(entry);
break;
case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry);
break;
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) async {
if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact();
}
await showContextMenuAt(details.globalPosition);
},
onSecondaryTapDown: (details) async {
await showContextMenuAt(details.globalPosition);
},
child: _ValueBlockView(block: block),
);
}
}
String? _formatDate(DateTime? date) {
if (date == null) return null;
return _dateFormat.format(date);
}
String _formatAttrLabel(String code) {
if (code.isEmpty) return 'Attribute';
final parts = code.split('_').where((p) => p.isNotEmpty).toList();
if (parts.isEmpty) return code;
return parts
.map((part) => part.length == 1
? part.toUpperCase()
: part[0].toUpperCase() + part.substring(1))
.join(' ');
}
DateTime? _parseDateString(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
DateTime? _effectiveStart(LocoAttrVersion entry) {
return entry.validFrom ??
_parseDateString(entry.maskedValidFrom) ??
entry.txnFrom;
}
DateTime _safeEnd(DateTime start, DateTime? end) {
if (end == null || !end.isAfter(start)) {
return start.add(const Duration(days: 1));
}
return end;
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final Map<String, List<_ValueBlock>> attrRows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) {
items.sort(
(a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now),
);
final segments = <_ValueSegment>[];
for (int i = 0; i < items.length; i++) {
final entry = items[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
start: start,
end: end,
value: entry.valueLabel,
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
}
attrSegments[attr] = segments;
});
minStart ??= now.subtract(const Duration(days: 1));
final effectiveMaxEnd = maxEnd ?? now;
final boundaryDates = <DateTime>{};
for (final segments in attrSegments.values) {
for (final seg in segments) {
boundaryDates.add(seg.start);
boundaryDates.add(seg.end);
}
}
boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) {
boundaries = [minStart!, effectiveMaxEnd];
}
final axisSegments = <_AxisSegment>[];
const double yearWidth = 240.0;
for (int i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i];
final end = boundaries[i + 1];
const width = yearWidth;
final double offset = axisSegments.isEmpty
? 0.0
: axisSegments.last.offset + axisSegments.last.width;
axisSegments.add(
_AxisSegment(
start: start,
end: end,
width: width,
offset: offset,
label: _formatDate(start) ?? '',
),
);
}
final axisTotalWidth =
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{};
for (final entry in attrSegments.entries) {
final blocks = <_ValueBlock>[];
for (final seg in entry.value) {
final left = _positionForDate(seg.start, boundaries, axisSegments);
final right = _positionForDate(seg.end, boundaries, axisSegments);
final span = right - left;
final width = span < 2.0 ? 2.0 : span;
blocks.add(
_ValueBlock(
left: left,
width: width,
cell: _RowCell.fromSegment(seg),
entry: seg.entry,
),
);
}
attrRows[entry.key] = blocks;
}
final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now';
return _TimelineModel(
axisSegments: axisSegments,
attrRows: attrRows,
endLabel: endLabel,
boundaries: boundaries,
axisTotalWidth: axisTotalWidth,
);
}
static double _positionForDate(
DateTime date,
List<DateTime> boundaries,
List<_AxisSegment> segments,
) {
for (int i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i];
final end = boundaries[i + 1];
if (!date.isAfter(end)) {
final seg = segments[i];
final span = end.difference(start).inMilliseconds;
final elapsed = date.difference(start).inMilliseconds.clamp(0, span);
if (span <= 0) return seg.offset;
final fraction = elapsed / span;
return seg.offset + (seg.width * fraction);
}
}
return segments.isNotEmpty
? segments.last.offset + segments.last.width
: 0.0;
}
}
class _AxisSegment {
final DateTime start;
final DateTime end;
final double width;
final double offset;
final String label;
_AxisSegment({
required this.start,
required this.end,
required this.width,
required this.offset,
required this.label,
});
}
class _ValueSegment {
final DateTime start;
final DateTime end;
final String value;
final LocoAttrVersion? entry;
_ValueSegment({
required this.start,
required this.end,
required this.value,
this.entry,
});
bool overlaps(DateTime s, DateTime e) {
return start.isBefore(e) && end.isAfter(s);
}
_ValueSegment copyWith({DateTime? start, DateTime? end, String? value}) {
return _ValueSegment(
start: start ?? this.start,
end: end ?? this.end,
value: value ?? this.value,
entry: entry,
);
}
}
class _RowCell {
final String value;
final String rangeLabel;
final Color color;
const _RowCell({
required this.value,
required this.rangeLabel,
required this.color,
});
factory _RowCell.fromSegment(_ValueSegment seg) {
if (seg.value.isEmpty) {
return const _RowCell(
value: '',
rangeLabel: '',
color: Colors.transparent,
);
}
final displayStart = _formatDate(seg.start) ?? '';
return _RowCell(
value: seg.value,
rangeLabel: displayStart,
color: _colorForValue(seg.value),
);
}
}
class _ValueBlock {
final double left;
final double width;
final _RowCell cell;
final LocoAttrVersion? entry;
const _ValueBlock({
required this.left,
required this.width,
required this.cell,
required this.entry,
});
double get right => left + width;
_ValueBlock copyWith({
double? left,
double? width,
_RowCell? cell,
LocoAttrVersion? entry,
}) {
return _ValueBlock(
left: left ?? this.left,
width: width ?? this.width,
cell: cell ?? this.cell,
entry: entry ?? this.entry,
);
}
}
Color _colorForValue(String value) {
final hue = (value.hashCode % 360).toDouble();
final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);
return hsl.toColor();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/calculator/calculator.dart';
import 'package:mileograph_flutter/components/pages/traction.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/navigation_guard.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'new_entry_page.dart';
part 'new_entry_drafts.dart';
part 'new_entry_picker_pages.dart';
part 'new_entry_models.dart';
part 'new_entry_draft_logic.dart';
part 'new_entry_submit_logic.dart';
part 'new_entry_traction_logic.dart';
const String _kDraftPrefsKey = 'new_entry_draft';
const String _kDraftListPrefsKey = 'new_entry_drafts_list';

View File

@@ -0,0 +1,367 @@
part of 'new_entry.dart';
extension _NewEntryDraftLogic on _NewEntryPageState {
Future<bool> _handleExitIntent() async {
if (!mounted) return false;
if (_isEditing) return true;
if (_formIsEmpty()) return true;
if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true;
}
final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) {
await _saveDraftEntry(draftId: _activeDraftId);
} else if (choice == _ExitChoice.discard) {
await _resetFormState(clearDraft: true);
_activeDraftId = null;
}
return true;
}
bool _draftChangedFromBaseline() {
if (_loadedDraftSnapshot == null) return true;
final current = _buildDraftSnapshot(
id: _activeDraftId ?? 'temp',
includeTimestamp: false,
);
return !_snapshotEquality.equals(_loadedDraftSnapshot, current);
}
bool _formIsEmpty() {
return _startController.text.trim().isEmpty &&
_endController.text.trim().isEmpty &&
_headcodeController.text.trim().isEmpty &&
_notesController.text.trim().isEmpty &&
_networkController.text.trim().isEmpty &&
_mileageController.text.trim().isEmpty &&
_routeResult == null &&
_tractionItems.length <= 1;
}
Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel;
final result = await showDialog<_ExitChoice>(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (_) => AlertDialog(
title: const Text('Save draft?'),
content: const Text(
'Do you want to save this entry as a draft before leaving?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.save),
child: const Text('Yes'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel),
child: const Text('Cancel'),
),
],
),
);
return result ?? _ExitChoice.cancel;
}
Future<void> _openDrafts() async {
final selected = await Navigator.of(context).push<_StoredDraft>(
MaterialPageRoute(
builder: (_) => _DraftListPage(
loadDrafts: _loadSavedDrafts,
onDeleteDraft: _deleteDraft,
),
),
);
if (selected != null) {
_activeDraftId = selected.id;
await _loadDraftEntry(selected.data);
}
}
Future<void> _saveDraftManually() async {
if (_savingDraft) return;
if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Nothing to save yet.')),
);
return;
}
final hadDraft = _activeDraftId != null;
_setState(() => _savingDraft = true);
try {
await _saveDraftEntry(draftId: _activeDraftId);
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(hadDraft ? 'Draft updated' : 'Draft saved')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Failed to save draft: $e')),
);
} finally {
if (mounted) _setState(() => _savingDraft = false);
}
}
Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled) return;
final prefs = await SharedPreferences.getInstance();
final draft = {
"date": _selectedDate.toIso8601String(),
"time": {"hour": _selectedTime.hour, "minute": _selectedTime.minute},
"start": _startController.text,
"end": _endController.text,
"headcode": _headcodeController.text,
"notes": _notesController.text,
"mileage": _mileageController.text,
"network": _networkController.text,
"useManualMileage": _useManualMileage,
"selectedTripId": _selectedTripId,
"routeResult": _routeResult == null
? null
: {
"input_route": _routeResult!.inputRoute,
"calculated_route": _routeResult!.calculatedRoute,
"costs": _routeResult!.costs,
"distance": _routeResult!.distance,
},
"tractionItems": _serializeTractionItems(),
};
await prefs.setString(_kDraftPrefsKey, jsonEncode(draft));
}
Future<void> _clearDraft() async {
if (!_draftPersistenceEnabled) return;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kDraftPrefsKey);
}
Future<List<_StoredDraft>> _loadSavedDrafts() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kDraftListPrefsKey);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return [];
return decoded
.whereType<Map>()
.map((e) => _StoredDraft.fromJson(Map<String, dynamic>.from(e)))
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
} catch (_) {
return [];
}
}
Future<void> _deleteDraft(String id) async {
final drafts = await _loadSavedDrafts();
drafts.removeWhere((d) => d.id == id);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_kDraftListPrefsKey,
jsonEncode(drafts.map((e) => e.toJson()).toList()),
);
if (_activeDraftId == id) {
_activeDraftId = null;
}
}
Future<String> _saveDraftEntry({String? draftId}) async {
final id = draftId ?? DateTime.now().microsecondsSinceEpoch.toString();
final snapshot = _buildDraftSnapshot(id: id);
final drafts = await _loadSavedDrafts();
final now = DateTime.now();
final existingIndex = drafts.indexWhere((d) => d.id == id);
final newDraft = _StoredDraft(id: id, savedAt: now, data: snapshot);
if (existingIndex >= 0) {
drafts[existingIndex] = newDraft;
} else {
drafts.insert(0, newDraft);
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_kDraftListPrefsKey,
jsonEncode(drafts.map((e) => e.toJson()).toList()),
);
_activeDraftId = id;
_loadedDraftSnapshot = _buildDraftSnapshot(id: id, includeTimestamp: false);
return id;
}
Map<String, dynamic> _buildDraftSnapshot({
required String id,
bool includeTimestamp = true,
}) {
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
? _startController.text.trim()
: (routeStations.isNotEmpty ? routeStations.first : '');
final endVal = _useManualMileage
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final payload = _useManualMileage
? {
"leg_trip": _selectedTripId,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(),
"leg_distance": mileageVal,
"isKilometers": false,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
}
: {
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_route": routeStations,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
"leg_mileage": _routeResult?.distance ?? mileageVal,
};
return {
"id": id,
if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(),
"mode": _useManualMileage ? 'manual' : 'auto',
"payload": payload,
"mileageText": _mileageController.text.trim(),
"routeResult": _routeResult == null
? null
: {
"input_route": _routeResult!.inputRoute,
"calculated_route": _routeResult!.calculatedRoute,
"costs": _routeResult!.costs,
"distance": _routeResult!.distance,
},
"tractionItems": _serializeTractionItems(),
};
}
Future<void> _loadDraftEntry(Map<String, dynamic> data) async {
if (!mounted) return;
final payloadRaw = data['payload'];
if (payloadRaw is! Map) return;
final payload = Map<String, dynamic>.from(payloadRaw);
final mode = data['mode'] as String?;
final useManual =
mode == 'manual' ||
(payload.containsKey('leg_distance') &&
!payload.containsKey('leg_route'));
final beginStr = payload['leg_begin_time'] as String?;
final beginTime = beginStr == null
? DateTime.now()
: DateTime.tryParse(beginStr) ?? DateTime.now();
final tripRaw = payload['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null;
List<String> routeStations = [];
RouteResult? restoredRouteResult;
if (!useManual) {
if (payload['leg_route'] is List) {
routeStations = (payload['leg_route'] as List)
.map((e) => e.toString())
.toList();
}
final rr = data['routeResult'];
if (rr is Map<String, dynamic>) {
restoredRouteResult = RouteResult(
inputRoute:
(rr['input_route'] as List?)?.map((e) => e.toString()).toList() ??
routeStations,
calculatedRoute:
(rr['calculated_route'] as List?)
?.map((e) => e.toString())
.toList() ??
routeStations,
costs:
(rr['costs'] as List?)
?.map((e) => (e as num).toDouble())
.toList() ??
[],
distance:
(rr['distance'] as num?)?.toDouble() ??
(payload['leg_mileage'] as num?)?.toDouble() ??
0,
);
} else if (routeStations.isNotEmpty) {
restoredRouteResult = RouteResult(
inputRoute: routeStations,
calculatedRoute: routeStations,
costs: const [],
distance: (payload['leg_mileage'] as num?)?.toDouble() ?? 0,
);
}
}
_restoringDraft = true;
_setState(() {
_useManualMileage = useManual;
_selectedDate = beginTime;
_selectedTime = TimeOfDay.fromDateTime(beginTime);
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
_routeResult = restoredRouteResult;
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
.toUpperCase();
_networkController.text = (payload['leg_network'] as String? ?? '')
.toUpperCase();
_notesController.text = payload['leg_notes'] ?? '';
if (useManual) {
_startController.text = payload['leg_start'] ?? '';
_endController.text = payload['leg_end'] ?? '';
final miles = (payload['leg_distance'] as num?)?.toDouble();
_mileageController.text = miles == null || miles == 0
? ''
: miles.toStringAsFixed(2);
} else {
_startController.text =
routeStations.isNotEmpty ? routeStations.first : '';
_endController.text =
routeStations.isNotEmpty ? routeStations.last : '';
final dist = _routeResult?.distance ?? 0;
_mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2);
}
final tractionRaw = data['tractionItems'];
if (tractionRaw is List) {
_restoreTractionItems(
List<Map<String, dynamic>>.from(tractionRaw.cast<Map>()),
);
} else {
_tractionItems
..clear()
..add(_TractionItem.marker());
}
_lastSubmittedSnapshot = null;
final idRaw = data['id'];
if (idRaw != null) {
_activeDraftId = idRaw.toString();
}
});
final baselineId =
_activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString();
_loadedDraftSnapshot = _buildDraftSnapshot(
id: baselineId,
includeTimestamp: false,
);
_restoringDraft = false;
}
Future<void> _loadDraft() async {
// legacy single draft no-op
}
}

View File

@@ -0,0 +1,179 @@
part of 'new_entry.dart';
enum _ExitChoice { save, discard, cancel }
class _StoredDraft {
final String id;
final DateTime savedAt;
final Map<String, dynamic> data;
_StoredDraft({required this.id, required this.savedAt, required this.data});
factory _StoredDraft.fromJson(Map<String, dynamic> json) {
final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now();
final data = Map<String, dynamic>.from(json['data'] as Map? ?? {});
final embeddedId = data['id']?.toString();
return _StoredDraft(
id:
json['id']?.toString() ??
embeddedId ??
savedAt.microsecondsSinceEpoch.toString(),
savedAt: savedAt,
data: data,
);
}
Map<String, dynamic> toJson() {
return {"id": id, "saved_at": savedAt.toIso8601String(), "data": data};
}
}
class _DraftListPage extends StatelessWidget {
const _DraftListPage({required this.loadDrafts, required this.onDeleteDraft});
final Future<List<_StoredDraft>> Function() loadDrafts;
final Future<void> Function(String id) onDeleteDraft;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Drafts')),
body: FutureBuilder<List<_StoredDraft>>(
future: loadDrafts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final drafts = snapshot.data ?? const [];
if (drafts.isEmpty) {
return const Center(child: Text('No drafts saved yet.'));
}
return _DraftListBody(drafts: drafts, onDelete: onDeleteDraft);
},
),
);
}
}
class _DraftListBody extends StatefulWidget {
const _DraftListBody({required this.drafts, required this.onDelete});
final List<_StoredDraft> drafts;
final Future<void> Function(String id) onDelete;
@override
State<_DraftListBody> createState() => _DraftListBodyState();
}
class _DraftListBodyState extends State<_DraftListBody> {
late final List<_StoredDraft> _drafts = List.of(widget.drafts);
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: _drafts.length,
separatorBuilder: (context, _) => const Divider(height: 0),
itemBuilder: (context, index) {
final draft = _drafts[index];
final routeLine = _draftSubtitle(draft);
final metaLine = _draftMetaLine(draft);
return ListTile(
title: Text(DateFormat.yMMMd().add_jm().format(draft.savedAt)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (routeLine != null) Text(routeLine),
if (metaLine.isNotEmpty) Text(metaLine),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Delete draft',
icon: const Icon(Icons.delete),
onPressed: () => _confirmDelete(context, draft),
),
const Icon(Icons.chevron_right),
],
),
onTap: () => Navigator.of(context).pop(draft),
);
},
);
}
Future<void> _confirmDelete(BuildContext context, _StoredDraft draft) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Delete draft?'),
content: const Text('This draft will be removed permanently.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
await widget.onDelete(draft.id);
if (!mounted) return;
setState(() {
_drafts.removeWhere((d) => d.id == draft.id);
});
}
String? _draftSubtitle(_StoredDraft draft) {
final payload = draft.data['payload'];
if (payload is! Map) return null;
final map = Map<String, dynamic>.from(payload);
String start = map['leg_start']?.toString() ?? '';
String end = map['leg_end']?.toString() ?? '';
if (start.isEmpty && end.isEmpty) {
if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) {
start = (map['leg_route'] as List).first.toString();
end = (map['leg_route'] as List).last.toString();
}
}
if (start.isEmpty && end.isEmpty) return null;
if (start.isNotEmpty && end.isNotEmpty) {
return '$start$end';
}
return start.isNotEmpty ? start : end;
}
String _draftMetaLine(_StoredDraft draft) {
final payload = draft.data['payload'];
if (payload is! Map) return '';
final map = Map<String, dynamic>.from(payload);
final parts = <String>[];
if ((map['leg_trip'] as int? ?? 0) != 0) {
parts.add('Trip ${map['leg_trip']}');
}
final headcode = (map['leg_headcode'] as String? ?? '').trim();
if (headcode.isNotEmpty) parts.add('Headcode $headcode');
final network = (map['leg_network'] as String? ?? '').trim();
if (network.isNotEmpty) parts.add('Network $network');
final notes = (map['leg_notes'] as String? ?? '').trim();
if (notes.isNotEmpty) parts.add('Notes');
final mileage =
(map['leg_distance'] as num?)?.toDouble() ??
(map['leg_mileage'] as num?)?.toDouble();
if (mileage != null && mileage > 0) {
parts.add('${mileage.toStringAsFixed(1)} mi');
} else if (map['leg_route'] is List &&
(map['leg_route'] as List).isNotEmpty) {
parts.add('Route ${(map['leg_route'] as List).length} stops');
}
final locos = map['locos'];
if (locos is List && locos.isNotEmpty) {
parts.add('${locos.length} traction');
}
return parts.join('');
}
}

View File

@@ -0,0 +1,25 @@
part of 'new_entry.dart';
class _TractionItem {
final LocoSummary? loco;
final bool powering;
final bool isMarker;
_TractionItem({
required this.loco,
this.powering = true,
this.isMarker = false,
});
factory _TractionItem.marker() =>
_TractionItem(loco: null, powering: false, isMarker: true);
_TractionItem copyWith({LocoSummary? loco, bool? powering, bool? isMarker}) {
return _TractionItem(
loco: loco ?? this.loco,
powering: powering ?? this.powering,
isMarker: isMarker ?? this.isMarker,
);
}
}

View File

@@ -0,0 +1,799 @@
part of 'new_entry.dart';
class NewEntryPage extends StatefulWidget {
const NewEntryPage({super.key, this.editLegId});
final int? editLegId;
@override
State<NewEntryPage> createState() => _NewEntryPageState();
}
class _NewEntryPageState extends State<NewEntryPage> {
late final NavigationGuardCallback _exitGuard;
final _formKey = GlobalKey<FormState>();
DateTime _selectedDate = DateTime.now();
TimeOfDay _selectedTime = TimeOfDay.now();
final _startController = TextEditingController();
final _endController = TextEditingController();
final _headcodeController = TextEditingController();
final _notesController = TextEditingController();
final _mileageController = TextEditingController();
final _networkController = TextEditingController();
bool _submitting = false;
bool _useManualMileage = false;
RouteResult? _routeResult;
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
int? _selectedTripId;
bool _restoringDraft = false;
bool _loadingEdit = false;
bool _savingDraft = false;
String? _loadError;
Map<String, dynamic>? _lastSubmittedSnapshot;
Map<String, dynamic>? _loadedDraftSnapshot;
final DeepCollectionEquality _snapshotEquality =
const DeepCollectionEquality();
String? _activeDraftId;
bool get _isEditing => widget.editLegId != null;
bool get _draftPersistenceEnabled =>
false; // legacy single draft disabled in favor of draft list
@override
void initState() {
super.initState();
_exitGuard = _handleExitIntent;
NavigationGuard.register(_exitGuard);
// legacy single-draft auto-save listeners removed in favor of explicit multi-draft flow
Future.microtask(() {
if (!mounted) return;
final data = context.read<DataService>();
data.fetchClassList();
data.fetchTripOptions();
if (_draftPersistenceEnabled) {
_loadDraft();
}
if (_isEditing && widget.editLegId != null) {
_loadLegForEdit(widget.editLegId!);
}
});
}
@override
void dispose() {
NavigationGuard.unregister(_exitGuard);
_startController.dispose();
_endController.dispose();
_headcodeController.dispose();
_notesController.dispose();
_mileageController.dispose();
_networkController.dispose();
super.dispose();
}
void _setState(VoidCallback fn) {
if (!mounted) return;
// ignore: invalid_use_of_protected_member
setState(fn);
}
Widget _buildTripSelector(BuildContext context) {
final trips = context.watch<DataService>().tripList;
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
final tripIds = sorted.map((t) => t.tripId).toSet();
final selectedValue =
(_selectedTripId != null && tripIds.contains(_selectedTripId))
? _selectedTripId
: null;
return Row(
children: [
Expanded(
child: DropdownButtonFormField<int?>(
value: selectedValue,
decoration: const InputDecoration(
labelText: 'Trip',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('No trip')),
...sorted.map(
(t) => DropdownMenuItem<int?>(
value: t.tripId,
child: Text(t.tripName),
),
),
],
onChanged: (val) {
setState(() => _selectedTripId = val);
_saveDraft();
},
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => _showAddTripDialog(context),
icon: const Icon(Icons.add),
label: const Text('New Trip'),
),
],
);
}
Future<void> _showAddTripDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('New Trip'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Trip name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()),
child: const Text('Add'),
),
],
),
);
if (!context.mounted) {
controller.dispose();
return;
}
if (result != null && result.isNotEmpty) {
final api = context.read<ApiService>();
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final encoded = Uri.encodeComponent(result);
final res = await api.put('/trips/new?trip_name=$encoded', {});
await data.fetchTripOptions();
if (!context.mounted) return;
final trips = data.tripList;
final apiTripId = res is Map ? res['trip_id'] as int? : null;
TripSummary match;
try {
match = trips.firstWhere(
(t) =>
(apiTripId != null && t.tripId == apiTripId) ||
t.tripName == result,
);
} catch (_) {
match = TripSummary(
tripId: apiTripId ?? 0,
tripName: result,
tripMileage: 0,
);
data.upsertTripSummary(match);
}
setState(() => _selectedTripId = match.tripId);
_saveDraft();
} catch (e) {
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to add trip: $e')),
);
} finally {
controller.dispose();
}
} else {
controller.dispose();
}
}
Future<void> _openCalculator() async {
final initialStations = _routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute
: (_routeResult?.calculatedRoute ?? const []);
final result = await Navigator.of(context).push<RouteResult>(
MaterialPageRoute(
builder: (_) => _CalculatorPickerPage(
initialStations: initialStations.isEmpty ? null : initialStations,
onResult: (res) => Navigator.of(context).pop(res),
),
),
);
if (result != null) {
setState(() {
_routeResult = result;
_mileageController.text = result.distance.toStringAsFixed(2);
_useManualMileage = false;
});
_saveDraft();
}
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(1970),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => _selectedDate = picked);
_saveDraft();
}
Future<void> _pickTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _selectedTime,
);
if (picked != null) {
setState(() => _selectedTime = picked);
_saveDraft();
}
}
Future<void> _loadLegForEdit(int legId) async {
setState(() {
_loadingEdit = true;
_loadError = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/legs/by-id/$legId');
if (!mounted) return;
if (json is! Map<String, dynamic>) {
throw Exception('Unexpected response for leg $legId');
}
final beginTime =
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
final routeStations = _parseRouteStations(json['leg_route']);
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
final useManual = routeStations.isEmpty;
final routeResult = useManual
? null
: RouteResult(
inputRoute: routeStations,
calculatedRoute: routeStations,
costs: const <double>[],
distance: mileageVal,
);
final tractionItems = _buildTractionFromApi(
(json['locos'] as List? ?? [])
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList(),
);
_restoringDraft = true;
setState(() {
final tripRaw = json['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null;
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
_selectedDate = beginTime;
_selectedTime = TimeOfDay.fromDateTime(beginTime);
_useManualMileage = useManual;
_routeResult = routeResult;
_startController.text = json['leg_start'] ?? '';
_endController.text = json['leg_end'] ?? '';
_headcodeController.text = (json['leg_headcode'] as String? ?? '')
.toUpperCase();
_notesController.text = json['leg_notes'] ?? '';
_networkController.text = (json['leg_network'] as String? ?? '')
.toUpperCase();
_mileageController.text = mileageVal == 0
? ''
: mileageVal.toStringAsFixed(2);
_tractionItems
..clear()
..addAll(tractionItems);
if (_tractionItems.where((e) => e.isMarker).isEmpty) {
_tractionItems.insert(0, _TractionItem.marker());
}
_lastSubmittedSnapshot = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loadError = 'Failed to load entry: $e';
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to load entry: $e')));
} finally {
_restoringDraft = false;
if (mounted) {
setState(() => _loadingEdit = false);
}
}
}
List<String> _parseRouteStations(dynamic raw) {
if (raw is List) {
return raw.map((e) => e.toString()).toList();
}
if (raw is String) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return [];
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {
// ignore and try alternative parsing
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
final replaced = trimmed.replaceAll("'", '"');
final decoded = jsonDecode(replaced);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
}
if (trimmed.contains('->')) {
return trimmed
.split('->')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (trimmed.contains(',')) {
return trimmed
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [trimmed];
}
return [];
}
DateTime get _legDateTime => DateTime(
_selectedDate.year,
_selectedDate.month,
_selectedDate.day,
_selectedTime.hour,
_selectedTime.minute,
);
@override
Widget build(BuildContext context) {
Widget body;
if (_isEditing && _loadingEdit) {
body = const Center(child: CircularProgressIndicator());
} else if (_isEditing && _loadError != null) {
body = Center(child: Text(_loadError!));
} else {
final isMobile = MediaQuery.of(context).size.width < 700;
body = Form(
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final twoCol = !isMobile && constraints.maxWidth > 1000;
final tractionEmpty = _tractionItems.length == 1;
final mileageEmpty = !_useManualMileage && _routeResult == null;
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
final balancedHeight = balancePanels ? 165.0 : null;
final detailPanel = _section('Details', [
Row(
children: [
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing ? null : _openDrafts,
icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'),
),
const SizedBox(width: 12),
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing || _savingDraft || _submitting
? null
: _saveDraftManually,
icon: _savingDraft
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_alt, size: 16),
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
),
const Spacer(),
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _submitting
? null
: () => _resetFormState(clearDraft: true),
icon: const Icon(Icons.clear, size: 16),
label: const Text('Clear form'),
),
],
),
_buildTripSelector(context),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickDate,
icon: const Icon(Icons.calendar_today),
label: Text(DateFormat.yMMMd().format(_selectedDate)),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _pickTime,
icon: const Icon(Icons.schedule),
label: Text(_selectedTime.format(context)),
),
),
],
),
if (_useManualMileage)
Row(
children: [
Expanded(
child: TextFormField(
controller: _startController,
decoration: const InputDecoration(
labelText: 'From',
border: OutlineInputBorder(),
),
validator: (v) => !_useManualMileage
? null
: (v == null || v.isEmpty ? 'Required' : null),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _endController,
decoration: const InputDecoration(
labelText: 'To',
border: OutlineInputBorder(),
),
validator: (v) => !_useManualMileage
? null
: (v == null || v.isEmpty ? 'Required' : null),
),
),
],
),
TextFormField(
controller: _headcodeController,
textCapitalization: TextCapitalization.characters,
inputFormatters: const [_UpperCaseTextFormatter()],
decoration: const InputDecoration(
labelText: 'Headcode',
border: OutlineInputBorder(),
),
),
TextFormField(
controller: _networkController,
textCapitalization: TextCapitalization.characters,
inputFormatters: const [_UpperCaseTextFormatter()],
decoration: const InputDecoration(
labelText: 'Network',
border: OutlineInputBorder(),
),
),
TextFormField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Notes',
border: OutlineInputBorder(),
),
),
]);
final tractionPanel = _section('Traction', [
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: _openTractionPicker,
icon: const Icon(Icons.search),
label: const Text('Search traction'),
),
),
_buildTractionList(),
], minHeight: balancedHeight);
final mileagePanel = _section(
'Mileage',
[
if (!_useManualMileage)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
minimumSize: const Size(0, 32),
),
onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'),
),
),
if (_useManualMileage)
TextFormField(
controller: _mileageController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Mileage (mi)',
border: OutlineInputBorder(),
),
)
else if (_routeResult != null)
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Calculated mileage'),
subtitle: Text(
'${_routeResult!.distance.toStringAsFixed(2)} mi',
),
)
else
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'No route selected. Use the calculator to add a route.',
),
),
],
trailing: FilterChip(
label: Text(_useManualMileage ? 'Manual' : 'Automatic'),
selected: _useManualMileage,
onSelected: (val) {
setState(() => _useManualMileage = val);
_saveDraft();
},
),
minHeight: balancedHeight,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
detailPanel,
const SizedBox(height: 16),
twoCol
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: tractionPanel),
const SizedBox(width: 16),
Expanded(child: mileagePanel),
],
)
: Column(
children: [
tractionPanel,
const SizedBox(height: 16),
mileagePanel,
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(
_submitting
? (_isEditing ? 'Saving...' : 'Submitting...')
: (_isEditing ? 'Save changes' : 'Submit entry'),
),
),
],
),
);
},
),
);
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final allow = await _handleExitIntent();
if (allow && context.mounted) {
final router = GoRouter.of(context);
if (router.canPop()) {
context.pop();
} else {
context.go('/');
}
}
},
child: Scaffold(
appBar: _isEditing
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (!await _handleExitIntent()) return;
if (!context.mounted) return;
final router = GoRouter.of(context);
if (router.canPop()) {
context.pop();
} else {
context.go('/');
}
},
),
title: const Text('Edit entry'),
)
: null,
body: body,
),
);
}
Widget _buildTractionList() {
if (_tractionItems.length == 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Text('No traction selected yet.'),
);
}
return ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
setState(() {
final item = _tractionItems.removeAt(oldIndex);
_tractionItems.insert(newIndex, item);
});
_saveDraft();
},
itemCount: _tractionItems.length,
itemBuilder: (context, index) {
final item = _tractionItems[index];
if (item.isMarker) {
return Card(
key: const ValueKey('marker'),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const ListTile(
leading: Icon(Icons.train),
title: Text('Rolling stock marker'),
subtitle: Text(
'Place locomotives above/below. Positions set relative to this.',
),
),
);
}
final loco = item.loco!;
final markerIndex = _tractionItems.indexWhere(
(element) => element.isMarker,
);
final pos = index > markerIndex
? -(index - markerIndex)
: (markerIndex - 1) - index;
return Card(
key: ValueKey('${loco.locoClass}-${loco.number}-$index'),
child: ListTile(
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_indicator),
),
title: Text('${loco.locoClass} ${loco.number}'),
subtitle: Text('${loco.name ?? ''} · Position $pos'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Powering'),
Switch(
value: item.powering,
onChanged: (v) {
setState(() {
_tractionItems[index] = item.copyWith(powering: v);
});
_saveDraft();
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
_tractionItems.removeAt(index);
});
_saveDraft();
},
),
],
),
),
);
},
);
}
Widget _section(
String title,
List<Widget> children, {
Widget? trailing,
double? minHeight,
}) {
Widget card = Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (trailing != null) trailing,
],
),
const SizedBox(height: 8),
...children.map(
(w) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: w,
),
),
],
),
),
);
if (minHeight != null) {
card = ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: card,
);
}
return card;
}
}
class _UpperCaseTextFormatter extends TextInputFormatter {
const _UpperCaseTextFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue.copyWith(
text: newValue.text.toUpperCase(),
selection: newValue.selection,
);
}
}

View File

@@ -0,0 +1,27 @@
part of 'new_entry.dart';
class _CalculatorPickerPage extends StatelessWidget {
const _CalculatorPickerPage({
required this.onResult,
this.initialStations,
});
final ValueChanged<RouteResult> onResult;
final List<String>? initialStations;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text('Mileage calculator'),
),
body: RouteCalculator(
onApplyRoute: onResult,
initialStations: initialStations,
),
);
}
}

View File

@@ -0,0 +1,221 @@
part of 'new_entry.dart';
extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async {
final missing = <String>[];
if (_useManualMileage) {
if (_startController.text.trim().isEmpty) missing.add('From');
if (_endController.text.trim().isEmpty) missing.add('To');
final mileageText = _mileageController.text.trim();
if (double.tryParse(mileageText) == null) {
missing.add('Mileage');
}
} else {
if (_routeResult == null || _routeResult!.calculatedRoute.isEmpty) {
missing.add('Route');
}
}
if (_networkController.text.trim().isEmpty) {
missing.add('Network');
}
if (missing.isEmpty) return true;
if (!mounted) return false;
final fieldList = missing.join(', ');
await showDialog<void>(
context: context,
useRootNavigator: false,
builder: (dialogCtx) => AlertDialog(
title: const Text('Required field missing'),
content: Text(
missing.length == 1
? 'Please fill the following field: $fieldList.'
: 'Please fill the following fields: $fieldList.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('OK'),
),
],
),
);
return false;
}
Future<void> _submit() async {
final form = _formKey.currentState;
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return;
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
? _startController.text.trim()
: (routeStations.isNotEmpty ? routeStations.first : '');
final endVal = _useManualMileage
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final snapshot = _buildSubmissionSnapshot(
routeStations: routeStations,
startVal: startVal,
endVal: endVal,
mileageVal: mileageVal,
tractionPayload: tractionPayload,
);
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
final confirmed = await _confirmDuplicateSubmission();
if (!confirmed) return;
}
if (!mounted) return;
final api = context.read<ApiService>();
final dataService = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
_setState(() => _submitting = true);
final isEditingExisting = _isEditing && widget.editLegId != null;
try {
if (_useManualMileage) {
final body = {
if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(),
"leg_distance": mileageVal,
"isKilometers": false,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
};
if (isEditingExisting) {
await api.put('/update', body);
} else {
await api.post('/add/manual', body);
}
} else {
final body = {
if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_route": routeStations,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
};
if (isEditingExisting) {
await api.put('/update', body);
} else {
await api.post('/add', body);
}
}
if (!mounted) return;
dataService.refreshLegs();
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(
content: Text(isEditingExisting ? 'Entry updated' : 'Entry submitted'),
),
);
_lastSubmittedSnapshot = snapshot;
_activeDraftId = null;
} catch (e) {
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to submit: $e')),
);
} finally {
if (mounted) _setState(() => _submitting = false);
}
}
Map<String, dynamic> _buildSubmissionSnapshot({
required List<String> routeStations,
required String startVal,
required String endVal,
required double mileageVal,
required List<Map<String, dynamic>> tractionPayload,
}) {
return {
"legId": widget.editLegId,
"useManualMileage": _useManualMileage,
"tripId": _selectedTripId,
"legDateTime": _legDateTime.toIso8601String(),
"start": startVal,
"end": endVal,
"routeStations": routeStations,
"mileage": mileageVal,
"network": _networkController.text.trim(),
"notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
"routeResult": _routeResult == null
? null
: {
"input_route": _routeResult!.inputRoute,
"calculated_route": _routeResult!.calculatedRoute,
"costs": _routeResult!.costs,
"distance": _routeResult!.distance,
},
};
}
Future<bool> _confirmDuplicateSubmission() async {
if (!mounted) return false;
final result = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Duplicate entry?'),
content: const Text('Entry already added, are you sure?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Submit anyway'),
),
],
),
);
return result ?? false;
}
Future<void> _resetFormState({bool clearDraft = false}) async {
_formKey.currentState?.reset();
_startController.clear();
_endController.clear();
_headcodeController.clear();
_notesController.clear();
_mileageController.clear();
_networkController.clear();
final now = DateTime.now();
_setState(() {
_selectedDate = now;
_selectedTime = TimeOfDay.fromDateTime(now);
_useManualMileage = false;
_routeResult = null;
_tractionItems
..clear()
..add(_TractionItem.marker());
_selectedTripId = null;
_submitting = false;
_activeDraftId = null;
_savingDraft = false;
_loadedDraftSnapshot = null;
});
if (clearDraft) {
await _clearDraft();
}
}
}

View File

@@ -0,0 +1,147 @@
part of 'new_entry.dart';
extension _NewEntryTractionLogic on _NewEntryPageState {
Future<void> _openTractionPicker() async {
final selectedKeys = _tractionItems
.where((e) => !e.isMarker && e.loco != null)
.map((e) => '${e.loco!.locoClass}-${e.loco!.number}')
.toSet();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TractionPage(
selectionMode: true,
selectedKeys: selectedKeys,
onSelect: (loco) {
final markerIndex = _tractionItems.indexWhere(
(element) => element.isMarker,
);
final key = '${loco.locoClass}-${loco.number}';
_setState(() {
final existingIndex = _tractionItems.indexWhere(
(e) =>
!e.isMarker &&
e.loco != null &&
'${e.loco!.locoClass}-${e.loco!.number}' == key,
);
if (existingIndex != -1) {
_tractionItems.removeAt(existingIndex);
} else {
_tractionItems.insert(
markerIndex,
_TractionItem(loco: loco, powering: true),
);
}
});
_saveDraft();
},
),
),
);
}
List<_TractionItem> _buildTractionFromApi(
List<Map<String, dynamic>> locoData,
) {
if (locoData.isEmpty) return [_TractionItem.marker()];
final sorted = [...locoData]
..sort((a, b) {
return _allocPos(b).compareTo(_allocPos(a));
});
final leading = sorted.where((e) => _allocPos(e) >= 0);
final trailing = sorted.where((e) => _allocPos(e) < 0);
return [
...leading.map(_mapLocoToTractionItem),
_TractionItem.marker(),
...trailing.map(_mapLocoToTractionItem),
];
}
int _allocPos(Map<String, dynamic> loco) =>
(loco['alloc_pos'] as num?)?.toInt() ?? 0;
_TractionItem _mapLocoToTractionItem(Map<String, dynamic> loco) {
final poweringRaw = loco['alloc_powering'];
final powering = poweringRaw == true || poweringRaw == 1;
return _TractionItem(loco: LocoSummary.fromJson(loco), powering: powering);
}
List<Map<String, dynamic>> _buildTractionPayload() {
final markerIndex = _tractionItems.indexWhere(
(element) => element.isMarker,
);
final payload = <Map<String, dynamic>>[];
for (var i = 0; i < _tractionItems.length; i++) {
final item = _tractionItems[i];
if (item.isMarker || item.loco == null) continue;
int allocPos;
if (i > markerIndex) {
allocPos = -(i - markerIndex);
} else {
allocPos = (markerIndex - 1) - i;
}
payload.add({
"loco_type": item.loco!.type,
"loco_number": item.loco!.number,
"alloc_pos": allocPos,
"alloc_powering": item.powering ? 1 : 0,
});
}
return payload;
}
List<Map<String, dynamic>> _serializeTractionItems() {
return _tractionItems
.map(
(item) => {
"isMarker": item.isMarker,
"powering": item.powering,
"loco": item.loco == null
? null
: {
"id": item.loco!.id,
"type": item.loco!.type,
"number": item.loco!.number,
"class": item.loco!.locoClass,
"name": item.loco!.name,
"operator": item.loco!.operator,
"notes": item.loco!.notes,
"evn": item.loco!.evn,
},
},
)
.toList();
}
void _restoreTractionItems(List<Map<String, dynamic>> items) {
final restored = <_TractionItem>[];
for (final item in items) {
final locoData = item['loco'] as Map<String, dynamic>?;
LocoSummary? loco;
if (locoData != null) {
loco = LocoSummary(
locoId: locoData['id'] ?? 0,
locoType: locoData['type'] ?? '',
locoNumber: locoData['number'] ?? '',
locoName: locoData['name'] ?? '',
locoClass: locoData['class'] ?? '',
locoOperator: locoData['operator'] ?? '',
locoNotes: locoData['notes'],
locoEvn: locoData['evn'],
);
}
restored.add(
_TractionItem(
loco: loco,
powering: item['powering'] ?? true,
isMarker: item['isMarker'] ?? false,
),
);
}
if (restored.where((e) => e.isMarker).isEmpty) {
restored.insert(0, _TractionItem.marker());
}
_tractionItems
..clear()
..addAll(restored);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
enum _SpeedUnit { kph, mph } enum _SpeedUnit { kph, mph }

View File

@@ -1,907 +1,2 @@
import 'package:flutter/material.dart'; export 'traction/traction.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class TractionPage extends StatefulWidget {
const TractionPage({
super.key,
this.selectionMode = false,
this.onSelect,
this.selectedKeys = const {},
});
final bool selectionMode;
final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys;
@override
State<TractionPage> createState() => _TractionPageState();
}
class _TractionPageState extends State<TractionPage> {
final _classController = TextEditingController();
final _classFocusNode = FocusNode();
final _numberController = TextEditingController();
final _nameController = TextEditingController();
bool _mileageFirst = true;
bool _initialised = false;
bool _showAdvancedFilters = false;
String? _selectedClass;
late Set<String> _selectedKeys;
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
@override
void initState() {
super.initState();
_classController.addListener(_onClassTextChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
final data = context.read<DataService>();
data.fetchClassList();
data.fetchEventFields();
_refreshTraction();
});
}
}
@override
void dispose() {
_classController.removeListener(_onClassTextChanged);
_classController.dispose();
_classFocusNode.dispose();
_numberController.dispose();
_nameController.dispose();
for (final controller in _dynamicControllers.values) {
controller.dispose();
}
super.dispose();
}
bool get _hasFilters {
final dynamicFieldsUsed =
_dynamicControllers.values.any(
(controller) => controller.text.trim().isNotEmpty,
) ||
_enumSelections.values.any(
(value) => (value ?? '').toString().trim().isNotEmpty,
);
return [
_selectedClass,
_classController.text,
_numberController.text,
_nameController.text,
].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
dynamicFieldsUsed;
}
Future<void> _refreshTraction({bool append = false}) async {
final data = context.read<DataService>();
final filters = <String, dynamic>{};
final name = _nameController.text.trim();
if (name.isNotEmpty) filters['name'] = name;
_dynamicControllers.forEach((key, controller) {
final value = controller.text.trim();
if (value.isNotEmpty) filters[key] = value;
});
_enumSelections.forEach((key, value) {
if (value != null && value.toString().trim().isNotEmpty) {
filters[key] = value;
}
});
final hadOnly = !_hasFilters;
await data.fetchTraction(
hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0,
append: append,
filters: filters,
mileageFirst: _mileageFirst,
);
}
void _clearFilters() {
for (final controller in [
_classController,
_numberController,
_nameController,
]) {
controller.clear();
}
for (final controller in _dynamicControllers.values) {
controller.clear();
}
_enumSelections.clear();
setState(() {
_selectedClass = null;
_mileageFirst = true;
});
_refreshTraction();
}
void _onClassTextChanged() {
if (_selectedClass != null &&
_classController.text.trim() != (_selectedClass ?? '')) {
setState(() {
_selectedClass = null;
});
}
}
List<EventField> _activeEventFields(List<EventField> fields) {
return fields
.where(
(field) => ![
'class',
'number',
'name',
'build date',
'build_date',
].contains(field.name.toLowerCase()),
)
.toList();
}
void _ensureControllersForFields(List<EventField> fields) {
for (final field in fields) {
if (field.enumValues != null) {
_enumSelections.putIfAbsent(field.name, () => null);
} else {
_dynamicControllers.putIfAbsent(
field.name,
() => TextEditingController(),
);
}
}
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final traction = data.traction;
final classOptions = data.locoClasses;
final isMobile = MediaQuery.of(context).size.width < 700;
_ensureControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields);
final listView = RefreshIndicator(
onRefresh: _refreshTraction,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fleet',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 2),
Text(
'Traction',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
if (mounted) {
_refreshTraction();
}
} else if (mounted && createdClass == '') {
_refreshTraction();
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
),
],
),
],
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filters',
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: _clearFilters,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: isMobile ? double.infinity : 240,
child: RawAutocomplete<String>(
textEditingController: _classController,
focusNode: _classFocusNode,
optionsBuilder: (TextEditingValue textEditingValue) {
final query = textEditingValue.text.toLowerCase();
if (query.isEmpty) {
return classOptions;
}
return classOptions.where(
(c) => c.toLowerCase().contains(query),
);
},
fieldViewBuilder:
(
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Class',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
);
},
optionsViewBuilder: (context, onSelected, options) {
final optionList = options.toList();
if (optionList.isEmpty) {
return const SizedBox.shrink();
}
final maxWidth = isMobile
? MediaQuery.of(context).size.width - 64
: 240.0;
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: 240,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionList.length,
itemBuilder: (context, index) {
final option = optionList[index];
return ListTile(
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
onSelected: (String selection) {
setState(() {
_selectedClass = selection;
_classController.text = selection;
});
_refreshTraction();
},
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _numberController,
decoration: const InputDecoration(
labelText: 'Number',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
FilterChip(
label: Text(
_mileageFirst ? 'Mileage first' : 'Number order',
),
selected: _mileageFirst,
onSelected: (v) {
setState(() => _mileageFirst = v);
_refreshTraction();
},
),
TextButton.icon(
onPressed: () => setState(
() => _showAdvancedFilters = !_showAdvancedFilters,
),
icon: Icon(
_showAdvancedFilters
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
_showAdvancedFilters
? 'Hide filters'
: 'More filters',
),
),
ElevatedButton.icon(
onPressed: _refreshTraction,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
],
),
AnimatedCrossFade(
crossFadeState: _showAdvancedFilters
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: data.isEventFieldsLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: extraFields.isEmpty
? const Text('No extra filters available right now.')
: Wrap(
spacing: 12,
runSpacing: 12,
children: extraFields
.map(
(field) => _buildFilterInput(
context,
field,
isMobile,
),
)
.toList(),
),
),
secondChild: const SizedBox.shrink(),
),
],
),
),
),
const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (traction.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'),
],
),
),
)
else
Column(
children: [
...traction.map((loco) => _buildTractionCard(context, loco)),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
if (data.isTractionLoading)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(context).colorScheme.surface.withOpacity(0.6),
child: const Center(child: CircularProgressIndicator()),
),
),
),
],
),
],
),
);
if (widget.selectionMode) {
return Scaffold(
appBar: AppBar(
leadingWidth: 140,
leading: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
title: null,
),
body: listView,
);
}
return listView;
}
Widget _buildTractionCard(BuildContext context, LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}';
final isSelected = _selectedKeys.contains(keyVal);
final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? '';
final domain = loco.domain ?? '';
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final statusColors = _statusChipColors(context, status);
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
if (hasMileageOrTrips)
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
),
Text(
loco.locoClass,
style: Theme.of(context).textTheme.labelMedium,
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontStyle: FontStyle.italic),
),
),
],
),
Chip(
label: Text(status),
backgroundColor: statusColors.$1,
labelStyle: TextStyle(color: statusColors.$2),
),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () => _showLocoInfo(loco),
icon: const Icon(Icons.info_outline),
label: const Text('Details'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _openTimeline(loco),
icon: const Icon(Icons.timeline),
label: const Text('Timeline'),
),
const Spacer(),
if (widget.selectionMode)
TextButton.icon(
onPressed: () {
if (widget.onSelect != null) {
widget.onSelect!(loco);
}
setState(() {
if (isSelected) {
_selectedKeys.remove(keyVal);
} else {
_selectedKeys.add(keyVal);
}
});
},
icon: Icon(
isSelected
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
),
],
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_statPill(
context,
label: 'Miles',
value: _formatNumber(loco.mileage),
),
_statPill(
context,
label: 'Trips',
value: (loco.trips ?? loco.journeys ?? 0).toString(),
),
if (operatorName.isNotEmpty)
_statPill(context, label: 'Operator', value: operatorName),
if (domain.isNotEmpty)
_statPill(context, label: 'Domain', value: domain),
],
),
],
),
),
);
}
Widget _statPill(
BuildContext context, {
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label: ', style: Theme.of(context).textTheme.labelSmall),
Text(
value,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
(Color, Color) _statusChipColors(BuildContext context, String status) {
final scheme = Theme.of(context).colorScheme;
final isDark = scheme.brightness == Brightness.dark;
Color blend(
Color base, {
double bgOpacity = 0.18,
double fgOpacity = 0.82,
}) {
final bg = Color.alphaBlend(
base.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity),
scheme.surface,
);
final fg = Color.alphaBlend(
base.withOpacity(isDark ? fgOpacity : fgOpacity * 0.8),
scheme.onSurface,
);
return Color.lerp(bg, fg, 0.0) ?? bg;
}
Color background;
Color foreground;
final key = status.toLowerCase();
if (key.contains('scrap')) {
background = blend(Colors.red);
foreground = Colors.red.shade200.withOpacity(isDark ? 0.85 : 0.9);
} else if (key.contains('active')) {
background = blend(scheme.primary);
foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8);
} else if (key.contains('withdrawn')) {
background = blend(Colors.amber);
foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8);
} else if (key.contains('stored') || key.contains('unknown')) {
background = blend(Colors.grey);
foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75);
} else {
background = scheme.surfaceContainerHighest;
foreground = scheme.onSurface;
}
return (background, foreground);
}
void _openTimeline(LocoSummary loco) {
final label = '${loco.locoClass} ${loco.number}'.trim();
context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
}
Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
initialChildSize: 0.65,
builder: (_, controller) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
if (_hasMileageOrTrips(loco))
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
),
Text(
loco.locoClass,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
],
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 52.0, bottom: 12),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
Expanded(
child: ListView(
controller: controller,
children: [
_detailRow('Status', loco.status ?? 'Unknown'),
_detailRow('Operator', loco.operator ?? ''),
_detailRow('Domain', loco.domain ?? ''),
_detailRow('Owner', loco.owner ?? ''),
_detailRow('Livery', loco.livery ?? ''),
_detailRow('Location', loco.location ?? ''),
_detailRow('Mileage', _formatNumber(loco.mileage ?? 0)),
_detailRow(
'Trips',
(loco.trips ?? loco.journeys ?? 0).toString(),
),
_detailRow('EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow('Notes', loco.notes!),
],
),
),
],
),
);
},
);
},
);
}
Widget _detailRow(String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}
bool _hasMileageOrTrips(LocoSummary loco) {
final mileage = loco.mileage ?? 0;
final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0;
}
Widget _buildFilterInput(
BuildContext context,
EventField field,
bool isMobile,
) {
final width = isMobile ? double.infinity : 220.0;
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!
.map((e) => e.toString())
.toSet()
.toList();
final currentValue = _enumSelections[field.name];
final safeValue = options.contains(currentValue) ? currentValue : null;
return SizedBox(
width: width,
child: DropdownButtonFormField<String?>(
value: safeValue,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('Any')),
...options.map(
(value) => DropdownMenuItem(value: value, child: Text(value)),
),
],
onChanged: (val) {
setState(() {
_enumSelections[field.name] = val;
});
_refreshTraction();
},
),
);
}
final controller =
_dynamicControllers[field.name] ?? TextEditingController();
_dynamicControllers[field.name] = controller;
TextInputType? inputType;
if (field.type != null) {
final type = field.type!.toLowerCase();
if (type.contains('int') ||
type.contains('num') ||
type.contains('double')) {
inputType = const TextInputType.numberWithOptions(decimal: true);
}
}
return SizedBox(
width: width,
child: TextField(
controller: controller,
keyboardType: inputType,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
);
}
}

View File

@@ -0,0 +1,14 @@
import 'dart:convert';
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/data_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'traction_page.dart';
part 'traction_persistence.dart';
const String _kTractionSearchPrefsKey = 'traction_search_state_v1';

View File

@@ -0,0 +1,641 @@
part of 'traction.dart';
class TractionPage extends StatefulWidget {
const TractionPage({
super.key,
this.selectionMode = false,
this.onSelect,
this.selectedKeys = const {},
});
final bool selectionMode;
final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys;
@override
State<TractionPage> createState() => _TractionPageState();
}
class _TractionPageState extends State<TractionPage> {
final _classController = TextEditingController();
final _classFocusNode = FocusNode();
final _numberController = TextEditingController();
final _nameController = TextEditingController();
bool _mileageFirst = true;
bool _initialised = false;
bool _showAdvancedFilters = false;
String? _selectedClass;
late Set<String> _selectedKeys;
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
bool _restoredFromPrefs = false;
@override
void initState() {
super.initState();
_classController.addListener(_onClassTextChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
_initialLoad();
});
}
}
Future<void> _initialLoad() async {
final data = context.read<DataService>();
await _restoreSearchState();
data.fetchClassList();
data.fetchEventFields();
await _refreshTraction();
}
@override
void dispose() {
_classController.removeListener(_onClassTextChanged);
_persistSearchState();
_classController.dispose();
_classFocusNode.dispose();
_numberController.dispose();
_nameController.dispose();
for (final controller in _dynamicControllers.values) {
controller.dispose();
}
super.dispose();
}
void _setState(VoidCallback fn) {
if (!mounted) return;
// ignore: invalid_use_of_protected_member
setState(fn);
}
bool get _hasFilters {
final dynamicFieldsUsed =
_dynamicControllers.values.any(
(controller) => controller.text.trim().isNotEmpty,
) ||
_enumSelections.values.any(
(value) => (value ?? '').toString().trim().isNotEmpty,
);
return [
_selectedClass,
_classController.text,
_numberController.text,
_nameController.text,
].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
dynamicFieldsUsed;
}
Future<void> _refreshTraction({bool append = false}) async {
final data = context.read<DataService>();
final filters = <String, dynamic>{};
final name = _nameController.text.trim();
if (name.isNotEmpty) filters['name'] = name;
_dynamicControllers.forEach((key, controller) {
final value = controller.text.trim();
if (value.isNotEmpty) filters[key] = value;
});
_enumSelections.forEach((key, value) {
if (value != null && value.toString().trim().isNotEmpty) {
filters[key] = value;
}
});
final hadOnly = !_hasFilters;
await data.fetchTraction(
hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0,
append: append,
filters: filters,
mileageFirst: _mileageFirst,
);
await _persistSearchState();
}
void _clearFilters() {
for (final controller in [
_classController,
_numberController,
_nameController,
]) {
controller.clear();
}
for (final controller in _dynamicControllers.values) {
controller.clear();
}
_enumSelections.clear();
setState(() {
_selectedClass = null;
_mileageFirst = true;
});
_refreshTraction();
}
void _onClassTextChanged() {
if (_selectedClass != null &&
_classController.text.trim() != (_selectedClass ?? '')) {
setState(() {
_selectedClass = null;
});
}
}
List<EventField> _activeEventFields(List<EventField> fields) {
return fields
.where(
(field) => ![
'class',
'number',
'name',
'build date',
'build_date',
].contains(field.name.toLowerCase()),
)
.toList();
}
void _ensureControllersForFields(List<EventField> fields) {
for (final field in fields) {
if (field.enumValues != null) {
_enumSelections.putIfAbsent(field.name, () => null);
} else {
_dynamicControllers.putIfAbsent(
field.name,
() => TextEditingController(),
);
}
}
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final traction = data.traction;
final classOptions = data.locoClasses;
final isMobile = MediaQuery.of(context).size.width < 700;
_ensureControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields);
final listView = RefreshIndicator(
onRefresh: _refreshTraction,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fleet',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 2),
Text(
'Traction',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
if (mounted) {
_refreshTraction();
}
} else if (mounted && createdClass == '') {
_refreshTraction();
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
),
],
),
],
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filters',
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: _clearFilters,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: isMobile ? double.infinity : 240,
child: RawAutocomplete<String>(
textEditingController: _classController,
focusNode: _classFocusNode,
optionsBuilder: (TextEditingValue textEditingValue) {
final query = textEditingValue.text.toLowerCase();
if (query.isEmpty) {
return classOptions;
}
return classOptions.where(
(c) => c.toLowerCase().contains(query),
);
},
fieldViewBuilder:
(
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Class',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
);
},
optionsViewBuilder: (context, onSelected, options) {
final optionList = options.toList();
if (optionList.isEmpty) {
return const SizedBox.shrink();
}
final maxWidth = isMobile
? MediaQuery.of(context).size.width - 64
: 240.0;
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: 240,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionList.length,
itemBuilder: (context, index) {
final option = optionList[index];
return ListTile(
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
onSelected: (String selection) {
setState(() {
_selectedClass = selection;
_classController.text = selection;
});
_refreshTraction();
},
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _numberController,
decoration: const InputDecoration(
labelText: 'Number',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
FilterChip(
label: Text(
_mileageFirst ? 'Mileage first' : 'Number order',
),
selected: _mileageFirst,
onSelected: (v) {
setState(() => _mileageFirst = v);
_refreshTraction();
},
),
TextButton.icon(
onPressed: () => setState(
() => _showAdvancedFilters = !_showAdvancedFilters,
),
icon: Icon(
_showAdvancedFilters
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
_showAdvancedFilters
? 'Hide filters'
: 'More filters',
),
),
ElevatedButton.icon(
onPressed: _refreshTraction,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
],
),
AnimatedCrossFade(
crossFadeState: _showAdvancedFilters
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: data.isEventFieldsLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: extraFields.isEmpty
? const Text('No extra filters available right now.')
: Wrap(
spacing: 12,
runSpacing: 12,
children: extraFields
.map(
(field) => _buildFilterInput(
context,
field,
isMobile,
),
)
.toList(),
),
),
secondChild: const SizedBox.shrink(),
),
],
),
),
),
const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (traction.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'),
],
),
),
)
else
Column(
children: [
...traction.map(
(loco) => TractionCard(
loco: loco,
selectionMode: widget.selectionMode,
isSelected: _isSelected(loco),
onShowInfo: () => showTractionDetails(context, loco),
onOpenTimeline: () => _openTimeline(loco),
onOpenLegs: () => _openLegs(loco),
onToggleSelect:
widget.selectionMode ? () => _toggleSelection(loco) : null,
),
),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
if (data.isTractionLoading)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
child: const Center(child: CircularProgressIndicator()),
),
),
),
],
),
],
),
);
if (widget.selectionMode) {
return Scaffold(
appBar: AppBar(
leadingWidth: 140,
leading: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
title: null,
),
body: listView,
);
}
return listView;
}
void _toggleSelection(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}';
if (widget.onSelect != null) {
widget.onSelect!(loco);
}
setState(() {
if (_selectedKeys.contains(keyVal)) {
_selectedKeys.remove(keyVal);
} else {
_selectedKeys.add(keyVal);
}
});
}
bool _isSelected(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}';
return _selectedKeys.contains(keyVal);
}
Future<void> _openTimeline(LocoSummary loco) async {
final label = '${loco.locoClass} ${loco.number}'.trim();
await context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
if (!mounted) return;
await _refreshTraction();
}
Future<void> _openLegs(LocoSummary loco) async {
final label = '${loco.locoClass} ${loco.number}'.trim();
await context.push(
'/traction/${loco.id}/legs',
extra: {'label': label},
);
}
Widget _buildFilterInput(
BuildContext context,
EventField field,
bool isMobile,
) {
final width = isMobile ? double.infinity : 220.0;
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!
.map((e) => e.toString())
.toSet()
.toList();
final currentValue = _enumSelections[field.name];
final safeValue = options.contains(currentValue) ? currentValue : null;
return SizedBox(
width: width,
child: DropdownButtonFormField<String?>(
value: safeValue,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('Any')),
...options.map(
(value) => DropdownMenuItem(value: value, child: Text(value)),
),
],
onChanged: (val) {
setState(() {
_enumSelections[field.name] = val;
});
_refreshTraction();
},
),
);
}
final controller =
_dynamicControllers[field.name] ?? TextEditingController();
_dynamicControllers[field.name] = controller;
TextInputType? inputType;
if (field.type != null) {
final type = field.type!.toLowerCase();
if (type.contains('int') ||
type.contains('num') ||
type.contains('double')) {
inputType = const TextInputType.numberWithOptions(decimal: true);
}
}
return SizedBox(
width: width,
child: TextField(
controller: controller,
keyboardType: inputType,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
);
}
}

View File

@@ -0,0 +1,88 @@
part of 'traction.dart';
extension _TractionPersistence on _TractionPageState {
Future<void> _restoreSearchState() async {
if (widget.selectionMode) return;
if (_restoredFromPrefs) return;
_restoredFromPrefs = true;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kTractionSearchPrefsKey);
if (raw == null || raw.trim().isEmpty) return;
final decoded = jsonDecode(raw);
if (decoded is! Map) return;
final classText = decoded['classText']?.toString();
final numberText = decoded['number']?.toString();
final nameText = decoded['name']?.toString();
final selectedClass = decoded['selectedClass']?.toString();
final mileageFirst = decoded['mileageFirst'];
final showAdvanced = decoded['showAdvancedFilters'];
if (classText != null) _classController.text = classText;
if (numberText != null) _numberController.text = numberText;
if (nameText != null) _nameController.text = nameText;
final dynamicValues = <String, String>{};
final enumValues = <String, String?>{};
final dynamicRaw = decoded['dynamic'];
if (dynamicRaw is Map) {
for (final entry in dynamicRaw.entries) {
final key = entry.key.toString();
final val = entry.value?.toString() ?? '';
dynamicValues[key] = val;
}
}
final enumRaw = decoded['enum'];
if (enumRaw is Map) {
for (final entry in enumRaw.entries) {
enumValues[entry.key.toString()] = entry.value?.toString();
}
}
for (final entry in dynamicValues.entries) {
_dynamicControllers.putIfAbsent(
entry.key,
() => TextEditingController(text: entry.value),
);
_dynamicControllers[entry.key]?.text = entry.value;
}
for (final entry in enumValues.entries) {
_enumSelections[entry.key] = entry.value;
}
if (!mounted) return;
_setState(() {
_selectedClass =
(selectedClass != null && selectedClass.trim().isNotEmpty)
? selectedClass
: null;
if (mileageFirst is bool) _mileageFirst = mileageFirst;
if (showAdvanced is bool) _showAdvancedFilters = showAdvanced;
});
} catch (_) {
// Ignore preference restore failures.
}
}
Future<void> _persistSearchState() async {
if (widget.selectionMode) return;
final payload = <String, dynamic>{
'classText': _classController.text,
'number': _numberController.text,
'name': _nameController.text,
'selectedClass': _selectedClass,
'mileageFirst': _mileageFirst,
'showAdvancedFilters': _showAdvancedFilters,
'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)),
'enum': _enumSelections,
};
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kTractionSearchPrefsKey, jsonEncode(payload));
} catch (_) {
// Ignore persistence failures.
}
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TripsPage extends StatefulWidget { class TripsPage extends StatefulWidget {
@@ -26,6 +26,55 @@ class _TripsPageState extends State<TripsPage> {
await context.read<DataService>().fetchTripDetails(); await context.read<DataService>().fetchTripDetails();
} }
Future<void> _renameTrip(TripDetail trip, String newName) async {
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await api.post('/trips/rename', {
"trip_id": trip.id,
"trip_name": newName,
});
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to rename trip: $e')),
);
rethrow;
}
}
Future<String?> _promptTripName(BuildContext context, String initial) async {
final controller = TextEditingController(text: initial);
final newName = await showDialog<String>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Rename trip'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Trip name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Save'),
),
],
),
);
controller.dispose();
return newName;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
@@ -36,48 +85,60 @@ class _TripsPageState extends State<TripsPage> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: _refreshTrips, onRefresh: _refreshTrips,
child: ListView( child: ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: [ itemCount: () {
Row( if (showLoading) return 2;
mainAxisAlignment: MainAxisAlignment.spaceBetween, if (tripDetails.isEmpty && tripSummaries.isEmpty) return 2;
children: [ if (tripDetails.isEmpty) return 1 + tripSummaries.length;
Column( return 1 + tripDetails.length;
crossAxisAlignment: CrossAxisAlignment.start, }(),
children: [ itemBuilder: (context, index) {
Text( if (index == 0) {
'Journeys', return Column(
style: Theme.of(context).textTheme.labelMedium, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
const SizedBox(height: 2), Row(
Text( mainAxisAlignment: MainAxisAlignment.spaceBetween,
'Trips', children: [
style: Theme.of(context).textTheme.headlineSmall, Column(
), crossAxisAlignment: CrossAxisAlignment.start,
], children: [
), Text(
Row( 'Journeys',
children: [ style: Theme.of(context).textTheme.labelMedium,
IconButton( ),
onPressed: _refreshTrips, const SizedBox(height: 2),
icon: const Icon(Icons.refresh), Text(
tooltip: 'Refresh trips', 'Trips',
), style: Theme.of(context).textTheme.headlineSmall,
], ),
), ],
], ),
), IconButton(
const SizedBox(height: 12), onPressed: _refreshTrips,
if (showLoading) icon: const Icon(Icons.refresh),
const Center( tooltip: 'Refresh trips',
),
],
),
const SizedBox(height: 12),
],
);
}
if (showLoading) {
return const Center(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0), padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) );
else if (tripDetails.isEmpty && tripSummaries.isEmpty) }
Card(
if (tripDetails.isEmpty && tripSummaries.isEmpty) {
return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@@ -86,8 +147,8 @@ class _TripsPageState extends State<TripsPage> {
Text( Text(
'No trips yet', 'No trips yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
@@ -96,29 +157,22 @@ class _TripsPageState extends State<TripsPage> {
], ],
), ),
), ),
) );
else if (tripDetails.isEmpty) }
Column(
children: tripSummaries if (tripDetails.isEmpty) {
.map( final trip = tripSummaries[index - 1];
(trip) => Card( return Card(
child: ListTile( child: ListTile(
title: Text(trip.tripName), title: Text(trip.tripName),
subtitle: Text( subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
'${trip.tripMileage.toStringAsFixed(1)} mi', ),
), );
), }
),
) final trip = tripDetails[index - 1];
.toList(), return _buildTripCard(context, trip, isMobile);
) },
else
Column(
children: tripDetails
.map((trip) => _buildTripCard(context, trip, isMobile))
.toList(),
),
],
), ),
); );
} }
@@ -213,52 +267,85 @@ class _TripsPageState extends State<TripsPage> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
return SafeArea( bool renaming = false;
child: Padding( String tripName = trip.name;
padding: const EdgeInsets.all(16.0), return StatefulBuilder(
child: Column( builder: (sheetCtx, setSheetState) {
mainAxisSize: MainAxisSize.min, Future<void> handleRename() async {
crossAxisAlignment: CrossAxisAlignment.start, final newName =
children: [ await _promptTripName(sheetCtx, tripName) ?? tripName;
Row( if (newName.isEmpty || newName == tripName) return;
setSheetState(() => renaming = true);
try {
await _renameTrip(trip, newName);
tripName = newName;
setSheetState(() {});
} finally {
if (mounted) setSheetState(() => renaming = false);
}
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
IconButton( Row(
icon: const Icon(Icons.arrow_back), children: [
onPressed: () => Navigator.of(context).pop(), IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(sheetCtx).pop(),
),
Expanded(
child: Text(
tripName,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: renaming
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
tooltip: 'Rename trip',
onPressed: renaming ? null : handleRename,
),
const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
],
), ),
Text( const SizedBox(height: 8),
trip.name, SizedBox(
style: Theme.of(context).textTheme.titleMedium?.copyWith( height: MediaQuery.of(context).size.height * 0.6,
fontWeight: FontWeight.bold, child: ListView.builder(
itemCount: trip.legs.length,
itemBuilder: (context, index) {
final leg = trip.legs[index];
return ListTile(
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
);
},
), ),
), ),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
], ],
), ),
const SizedBox(height: 8), ),
SizedBox( );
height: MediaQuery.of(context).size.height * 0.6, },
child: ListView.builder(
itemCount: trip.legs.length,
itemBuilder: (context, index) {
final leg = trip.legs[index];
return ListTile(
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
);
},
),
),
],
),
),
); );
}, },
); );

View File

@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
class TractionCard extends StatelessWidget {
const TractionCard({
super.key,
required this.loco,
required this.selectionMode,
required this.isSelected,
required this.onShowInfo,
required this.onOpenTimeline,
this.onOpenLegs,
this.onToggleSelect,
});
final LocoSummary loco;
final bool selectionMode;
final bool isSelected;
final VoidCallback onShowInfo;
final VoidCallback onOpenTimeline;
final VoidCallback? onOpenLegs;
final VoidCallback? onToggleSelect;
@override
Widget build(BuildContext context) {
final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? '';
final domain = loco.domain ?? '';
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final statusColors = _statusChipColors(context, status);
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
if (hasMileageOrTrips)
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
),
Text(
loco.locoClass,
style: Theme.of(context).textTheme.labelMedium,
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontStyle: FontStyle.italic),
),
),
],
),
Chip(
label: Text(status),
backgroundColor: statusColors.$1,
labelStyle: TextStyle(color: statusColors.$2),
),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: onShowInfo,
icon: const Icon(Icons.info_outline),
label: const Text('Details'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: onOpenTimeline,
icon: const Icon(Icons.timeline),
label: const Text('Timeline'),
),
if (hasMileageOrTrips && onOpenLegs != null) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: onOpenLegs,
icon: const Icon(Icons.view_list),
label: const Text('Legs'),
),
],
const Spacer(),
if (selectionMode && onToggleSelect != null)
TextButton.icon(
onPressed: onToggleSelect,
icon: Icon(
isSelected
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
),
],
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_statPill(
context,
label: 'Miles',
value: _formatNumber(loco.mileage),
),
_statPill(
context,
label: 'Trips',
value: (loco.trips ?? loco.journeys ?? 0).toString(),
),
if (operatorName.isNotEmpty)
_statPill(context, label: 'Operator', value: operatorName),
if (domain.isNotEmpty)
_statPill(context, label: 'Domain', value: domain),
],
),
],
),
),
);
}
Widget _statPill(
BuildContext context, {
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label: ', style: Theme.of(context).textTheme.labelSmall),
Text(
value,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
}
Future<void> showTractionDetails(
BuildContext context,
LocoSummary loco,
) async {
final hasMileageOrTrips = _hasMileageOrTrips(loco);
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
initialChildSize: 0.65,
builder: (_, controller) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
if (hasMileageOrTrips)
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
),
Text(
loco.locoClass,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
],
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 52.0, bottom: 12),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
Expanded(
child: ListView(
controller: controller,
children: [
_detailRow(context, 'Status', loco.status ?? 'Unknown'),
_detailRow(context, 'Operator', loco.operator ?? ''),
_detailRow(context, 'Domain', loco.domain ?? ''),
_detailRow(context, 'Owner', loco.owner ?? ''),
_detailRow(context, 'Livery', loco.livery ?? ''),
_detailRow(context, 'Location', loco.location ?? ''),
_detailRow(
context,
'Mileage',
_formatNumber(loco.mileage ?? 0),
),
_detailRow(
context,
'Trips',
(loco.trips ?? loco.journeys ?? 0).toString(),
),
_detailRow(context, 'EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow(context, 'Notes', loco.notes!),
],
),
),
],
),
);
},
);
},
);
}
Widget _detailRow(BuildContext context, String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
(Color, Color) _statusChipColors(BuildContext context, String status) {
final scheme = Theme.of(context).colorScheme;
final isDark = scheme.brightness == Brightness.dark;
Color blend(
Color base, {
double bgOpacity = 0.18,
double fgOpacity = 0.82,
}) {
final bg = Color.alphaBlend(
base.withValues(alpha: isDark ? bgOpacity + 0.07 : bgOpacity),
scheme.surface,
);
final fg = Color.alphaBlend(
base.withValues(alpha: isDark ? fgOpacity : fgOpacity * 0.8),
scheme.onSurface,
);
return Color.lerp(bg, fg, 0.0) ?? bg;
}
Color background;
Color foreground;
final key = status.toLowerCase();
if (key.contains('scrap')) {
background = blend(Colors.red);
foreground = Colors.red.shade200.withValues(alpha: isDark ? 0.85 : 0.9);
} else if (key.contains('active')) {
background = blend(scheme.primary);
foreground = scheme.primary.withValues(alpha: isDark ? 0.9 : 0.8);
} else if (key.contains('withdrawn')) {
background = blend(Colors.amber);
foreground = Colors.amber.shade800.withValues(alpha: isDark ? 0.9 : 0.8);
} else if (key.contains('stored') || key.contains('unknown')) {
background = blend(Colors.grey);
foreground = Colors.grey.shade700.withValues(alpha: isDark ? 0.85 : 0.75);
} else {
background = scheme.surfaceContainerHighest;
foreground = scheme.onSurface;
}
return (background, foreground);
}
bool _hasMileageOrTrips(LocoSummary loco) {
final mileage = loco.mileage ?? 0;
final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0;
}
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}

View File

@@ -1,327 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:mileograph_flutter/app.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/services/apiService.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'components/login/login.dart';
import 'components/pages/dashboard.dart';
import 'package:go_router/go_router.dart';
late ApiService api;
void main() { void main() {
runApp( runApp(const App());
MultiProvider(
providers: [
Provider<ApiService>(
create: (_) {
api = ApiService(baseUrl: 'https://mileograph.co.uk/api/v1');
return api;
},
),
ChangeNotifierProxyProvider<ApiService, AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()),
update: (_, api, previous) {
return previous ?? AuthService(api: api);
},
),
ProxyProvider<AuthService, void>(
update: (_, auth, __) {
api.setTokenProvider(() => auth.token);
api.setUnauthorizedHandler(() => auth.handleTokenExpired());
},
),
ChangeNotifierProxyProvider<ApiService, DataService>(
create: (context) => DataService(api: context.read<ApiService>()),
update: (_, api, previous) => previous ?? DataService(api: api),
),
],
child: MyApp(),
),
);
} }
class MyApp extends StatelessWidget {
MyApp({super.key});
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
final GoRouter router = GoRouter(
refreshListenable: context
.read<AuthService>(), // `AuthService` extends `ChangeNotifier`
redirect: (context, state) {
final auth = Provider.of<AuthService>(context, listen: false);
final loggedIn = auth.isLoggedIn;
final loggingIn = state.uri.toString() == '/login';
// Redirect to login if not logged in and trying to access protected pages
if (!loggedIn && !loggingIn) return '/login';
// Redirect to home if already logged in and trying to go to login
if (loggedIn && loggingIn) return '/';
// No redirection
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) {
return MyHomePage(child: child);
},
routes: [
GoRoute(path: '/', builder: (_, __) => const Dashboard()),
GoRoute(path: '/calculator', builder: (_, __) => CalculatorPage()),
GoRoute(
path: '/calculator/details',
builder: (_, __) => CalculatorPage(),
),
GoRoute(path: '/legs', builder: (_, __) => LegsPage()),
GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
GoRoute(
path: '/traction/:id/timeline',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final locoId = int.tryParse(idParam ?? '') ?? 0;
final extra = state.extra;
String label = state.uri.queryParameters['label'] ?? '';
if (extra is Map && extra['label'] is String) {
label = extra['label'] as String;
} else if (extra is String && extra.isNotEmpty) {
label = extra;
}
if (label.trim().isEmpty) {
label = 'Loco $locoId';
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
);
},
),
GoRoute(
path: '/traction/new',
builder: (_, __) => const NewTractionPage(),
),
GoRoute(path: '/trips', builder: (_, __) => TripsPage()),
GoRoute(path: '/add', builder: (_, __) => NewEntryPage()),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final legId = idParam == null ? null : int.tryParse(idParam);
return NewEntryPage(editLegId: legId);
},
),
],
),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
],
);
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
return MaterialApp.router(
title: 'Flutter Demo',
routerConfig: router,
theme: ThemeData(
useMaterial3: true,
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//fullPage
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: lightDynamic ?? defaultLight,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark,
),
themeMode: ThemeMode.system,
);
},
);
}
}
class MyHomePage extends StatefulWidget {
final Widget child;
const MyHomePage({super.key, required this.child});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<String> contentPages = [
"/",
"/calculator",
"/legs",
"/traction",
"/trips",
"/add",
];
int _getIndexFromLocation(String location) {
int newIndex = contentPages.indexWhere((path) {
if (location == path) return true;
if (path == '/') return location == '/';
return location.startsWith('$path/');
});
if (newIndex < 0) {
return 0;
}
return newIndex;
}
Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) {
return;
}
await NavigationGuard.attemptNavigation(() async {
if (!mounted) return;
context.go(contentPages[index]);
});
}
bool loggedIn = false;
bool _fetched = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_fetched) {
_fetched = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future(() async {
final data = context.read<DataService>();
final auth = context.read<AuthService>();
api.setTokenProvider(() => auth.token);
await auth.tryRestoreSession();
if (!auth.isLoggedIn) return;
data.fetchEventFields();
if (data.homepageStats == null) {
data.fetchHomepageStats();
}
if (data.legs.isEmpty) {
data.fetchLegs();
}
if (data.traction.isEmpty) {
data.fetchHadTraction();
}
if (data.onThisDay.isEmpty) {
data.fetchOnThisDay();
}
if (data.tripDetails.isEmpty) {
data.fetchTripDetails();
}
});
});
}
}
@override
Widget build(BuildContext context) {
Widget currentPage;
final location = GoRouterState.of(context).uri.toString();
final pageIndex = _getIndexFromLocation(location);
final data = context.watch<DataService>();
final auth = context.read<AuthService>();
if (data.homepageStats != null || !data.isHomepageLoading) {
currentPage = widget.child;
} else {
currentPage = Center(child: CircularProgressIndicator());
}
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text.rich(
TextSpan(
children: [
TextSpan(text: "Mile"),
TextSpan(
text: "O",
style: TextStyle(color: Colors.red),
),
TextSpan(text: "graph"),
],
style: TextStyle(
decoration: TextDecoration.none,
color: Colors.white,
fontFamily: "Tomatoes",
),
),
),
actions: [
IconButton(onPressed: null, icon: Icon(Icons.account_circle)),
IconButton(onPressed: auth.logout, icon: Icon(Icons.logout)),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: pageIndex,
onDestinationSelected: (int index) {
_onItemTapped(index, pageIndex);
},
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: "Home"),
NavigationDestination(icon: Icon(Icons.route), label: "Calculator"),
NavigationDestination(icon: Icon(Icons.list), label: "Entries"),
NavigationDestination(icon: Icon(Icons.train), label: "Traction"),
NavigationDestination(icon: Icon(Icons.book), label: "Trips"),
NavigationDestination(icon: Icon(Icons.add), label: "Add"),
],
),
body: currentPage,
);
}
}

View File

@@ -1,6 +1,40 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
int _asInt(dynamic value, [int? fallback]) {
if (value is int) return value;
if (value is num) return value.toInt();
final parsed = int.tryParse(value?.toString() ?? '');
return parsed ?? fallback ?? 0;
}
double _asDouble(dynamic value, [double fallback = 0]) {
if (value is double) return value;
if (value is num) return value.toDouble();
final parsed = double.tryParse(value?.toString() ?? '');
return parsed ?? fallback;
}
String _asString(dynamic value, [String fallback = '']) {
final str = value?.toString();
return (str == null) ? fallback : str;
}
bool _asBool(dynamic value, [bool fallback = false]) {
if (value is bool) return value;
if (value is num) return value != 0;
final lower = value?.toString().toLowerCase();
if (lower == 'true' || lower == 'yes' || lower == '1') return true;
if (lower == 'false' || lower == 'no' || lower == '0') return false;
return fallback;
}
DateTime _asDateTime(dynamic value, [DateTime? fallback]) {
if (value is DateTime) return value;
final parsed = DateTime.tryParse(value?.toString() ?? '');
return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0));
}
class DestinationObject { class DestinationObject {
const DestinationObject( const DestinationObject(
this.label, this.label,
@@ -16,24 +50,24 @@ class DestinationObject {
} }
class UserData { class UserData {
const UserData(this.username, this.full_name, this.user_id, this.email); const UserData(this.username, this.fullName, this.userId, this.email);
final String user_id; final String userId;
final String username; final String username;
final String full_name; final String fullName;
final String email; final String email;
} }
class AuthenticatedUserData extends UserData { class AuthenticatedUserData extends UserData {
const AuthenticatedUserData({ const AuthenticatedUserData({
required String user_id, required String userId,
required String username, required String username,
required String full_name, required String fullName,
required String email, required String email,
required this.access_token, required this.accessToken,
}) : super(username, full_name, user_id, email); }) : super(username, fullName, userId, email);
final String access_token; final String accessToken;
} }
class HomepageStats { class HomepageStats {
@@ -42,6 +76,7 @@ class HomepageStats {
final List<LocoSummary> topLocos; final List<LocoSummary> topLocos;
final List<LeaderboardEntry> leaderboard; final List<LeaderboardEntry> leaderboard;
final List<TripSummary> trips; final List<TripSummary> trips;
final int legCount;
final UserData? user; final UserData? user;
HomepageStats({ HomepageStats({
@@ -50,6 +85,7 @@ class HomepageStats {
required this.topLocos, required this.topLocos,
required this.leaderboard, required this.leaderboard,
required this.trips, required this.trips,
required this.legCount,
this.user, this.user,
}); });
@@ -73,6 +109,10 @@ class HomepageStats {
trips: (json['trip_data'] as List? ?? []) trips: (json['trip_data'] as List? ?? [])
.map((e) => TripSummary.fromJson(e)) .map((e) => TripSummary.fromJson(e))
.toList(), .toList(),
legCount: _asInt(
json['leg_count'],
(json['trip_legs'] as List?)?.length ?? 0,
),
user: userData == null user: userData == null
? null ? null
: UserData( : UserData(
@@ -92,8 +132,8 @@ class YearlyMileage {
YearlyMileage({this.year, required this.mileage}); YearlyMileage({this.year, required this.mileage});
factory YearlyMileage.fromJson(Map<String, dynamic> json) => YearlyMileage( factory YearlyMileage.fromJson(Map<String, dynamic> json) => YearlyMileage(
year: json['year'], year: json['year'] is int ? json['year'] as int : int.tryParse('${json['year']}'),
mileage: (json['mileage'] as num).toDouble(), mileage: _asDouble(json['mileage']),
); );
} }
@@ -101,6 +141,7 @@ class Loco {
final int id; final int id;
final String type, number, locoClass; final String type, number, locoClass;
final String? name, operator, notes, evn; final String? name, operator, notes, evn;
final bool powering;
Loco({ Loco({
required this.id, required this.id,
@@ -111,6 +152,7 @@ class Loco {
required this.operator, required this.operator,
this.notes, this.notes,
this.evn, this.evn,
this.powering = true,
}); });
factory Loco.fromJson(Map<String, dynamic> json) => Loco( factory Loco.fromJson(Map<String, dynamic> json) => Loco(
@@ -122,6 +164,7 @@ class Loco {
operator: json['operator'], operator: json['operator'],
notes: json['notes'], notes: json['notes'],
evn: json['evn'], evn: json['evn'],
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
); );
} }
@@ -154,6 +197,7 @@ class LocoSummary extends Loco {
this.livery, this.livery,
this.location, this.location,
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
bool powering = true,
}) : extra = extra ?? const {}, }) : extra = extra ?? const {},
super( super(
id: locoId, id: locoId,
@@ -163,6 +207,7 @@ class LocoSummary extends Loco {
operator: locoOperator, operator: locoOperator,
notes: locoNotes, notes: locoNotes,
evn: locoEvn, evn: locoEvn,
powering: powering,
); );
factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary( factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary(
@@ -188,6 +233,7 @@ class LocoSummary extends Loco {
livery: json['livery'], livery: json['livery'],
location: json['location'], location: json['location'],
extra: Map<String, dynamic>.from(json), extra: Map<String, dynamic>.from(json),
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
); );
} }
@@ -328,6 +374,96 @@ class LocoAttrVersion {
} }
} }
class LocoChange {
final int locoId;
final String locoClass;
final String locoNumber;
final String locoName;
final String attrCode;
final String attrDisplay;
final String valueDisplay;
final DateTime? validFrom;
final DateTime? approvedAt;
final String approvedBy;
const LocoChange({
required this.locoId,
required this.locoClass,
required this.locoNumber,
required this.locoName,
required this.attrCode,
required this.attrDisplay,
required this.valueDisplay,
required this.validFrom,
required this.approvedAt,
required this.approvedBy,
});
factory LocoChange.fromJson(Map<String, dynamic> json) {
String _clean(dynamic value) {
final str = value?.toString().trim() ?? '';
if (str.isEmpty || str == '-' || str == '?') return '';
return str;
}
final valueLabel = json['value_norm'] ??
json['value_display'] ??
json['value_label'] ??
json['value_str'] ??
json['value_enum'] ??
json['value_norm'] ??
json['value'];
final approvedRaw = json['approved_at'] ?? json['approvedAt'];
final validFromRaw = json['valid_from'] ?? json['validFrom'];
return LocoChange(
locoId: _asInt(json['loco_id']),
locoClass: _clean(json['loco_class']),
locoNumber: _clean(json['loco_number']),
locoName: _clean(json['loco_name']),
attrCode: _asString(json['attr_code']),
attrDisplay: _clean(json['attr_display']),
valueDisplay: _clean(valueLabel),
validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''),
approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''),
approvedBy: _clean(json['approved_by']),
);
}
String get locoLabel {
final parts = [locoClass, locoNumber]
.map((e) => e.trim())
.where((e) => e.isNotEmpty && e != '-')
.toList();
final label = parts.join(' ');
if (label.isEmpty) return locoName.isNotEmpty ? locoName : 'Loco $locoId';
return locoName.trim().isEmpty ? label : '$label${locoName.trim()}';
}
String get changeLabel =>
_cleanLabel(attrDisplay).isNotEmpty
? _cleanLabel(attrDisplay)
: _cleanLabel(attrCode).toUpperCase();
String get approvedDateLabel {
final date = approvedAt ?? validFrom;
if (date == null) return 'Pending date';
return DateFormat('yyyy-MM-dd').format(date);
}
String get valueLabel {
final value = _cleanLabel(valueDisplay);
if (value.isNotEmpty) return value;
return 'Unknown value';
}
String _cleanLabel(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return '';
if (trimmed == '-' || trimmed == '?') return '';
return trimmed;
}
}
class LeaderboardEntry { class LeaderboardEntry {
final String userId, username, userFullName; final String userId, username, userFullName;
final double mileage; final double mileage;
@@ -341,10 +477,10 @@ class LeaderboardEntry {
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) => factory LeaderboardEntry.fromJson(Map<String, dynamic> json) =>
LeaderboardEntry( LeaderboardEntry(
userId: json['user_id'], userId: _asString(json['user_id']),
username: json['username'], username: _asString(json['username']),
userFullName: json['user_full_name'], userFullName: _asString(json['user_full_name']),
mileage: (json['mileage'] as num).toDouble(), mileage: _asDouble(json['mileage']),
); );
} }
@@ -360,9 +496,9 @@ class TripSummary {
}); });
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary( factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
tripId: json['trip_id'], tripId: _asInt(json['trip_id']),
tripName: json['trip_name'], tripName: _asString(json['trip_name']),
tripMileage: (json['trip_mileage'] as num).toDouble(), tripMileage: _asDouble(json['trip_mileage']),
); );
} }
@@ -391,21 +527,22 @@ class Leg {
}); });
factory Leg.fromJson(Map<String, dynamic> json) => Leg( factory Leg.fromJson(Map<String, dynamic> json) => Leg(
id: json['leg_id'], id: _asInt(json['leg_id']),
tripId: json['leg_trip'] ?? 0, tripId: _asInt(json['leg_trip']),
start: json['leg_start'], start: _asString(json['leg_start']),
end: json['leg_end'], end: _asString(json['leg_end']),
beginTime: DateTime.parse(json['leg_begin_time']), beginTime: _asDateTime(json['leg_begin_time']),
timezone: (json['leg_timezone'] as num).toInt(), timezone: _asInt(json['leg_timezone']),
network: json['leg_network'] ?? "", network: _asString(json['leg_network']),
route: json['leg_route'], route: _asString(json['leg_route']),
mileage: (json['leg_mileage'] as num).toDouble(), mileage: _asDouble(json['leg_mileage']),
notes: json['leg_notes'] ?? "", notes: _asString(json['leg_notes']),
headcode: json['leg_headcode'] ?? "", headcode: _asString(json['leg_headcode']),
driving: json['leg_driving'], driving: _asInt(json['leg_driving']),
user: json['leg_user'], user: _asString(json['leg_user']),
locos: (json['locos'] as List) locos: (json['locos'] is List ? (json['locos'] as List) : const [])
.map((e) => Loco.fromJson(e as Map<String, dynamic>)) .whereType<Map>()
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
.toList(), .toList(),
); );
} }
@@ -458,10 +595,10 @@ class Station {
}); });
factory Station.fromJson(Map<String, dynamic> json) => Station( factory Station.fromJson(Map<String, dynamic> json) => Station(
id: json['id'], id: _asInt(json['id']),
name: json['name'], name: _asString(json['name']),
network: json['network'], network: _asString(json['network']),
country: json['country'], country: _asString(json['country']),
); );
} }

View File

@@ -1,106 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
class ApiService {
final String baseUrl;
TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized;
ApiService({required this.baseUrl});
void setTokenProvider(TokenProvider provider) {
_getToken = provider;
}
void setUnauthorizedHandler(UnauthorizedHandler handler) {
_onUnauthorized = handler;
}
Map<String, String> _buildHeaders(Map<String, String>? extra) {
final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
final response = await http.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
);
return _processResponse(response);
}
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await http.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await http.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json',
}),
body: data, // http package handles form-encoding for Map<String, String>
);
return _processResponse(response);
}
Future<dynamic> put(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await http.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
}) async {
final response = await http.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
);
return _processResponse(response);
}
Map<String, String> _jsonHeaders(Map<String, String>? extra) {
return {'Content-Type': 'application/json', if (extra != null) ...extra};
}
Future<dynamic> _processResponse(http.Response res) async {
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
if (res.statusCode >= 200 && res.statusCode < 300) {
return body;
}
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
throw Exception('API error ${res.statusCode}: $body');
}
}

View File

@@ -0,0 +1,145 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
class ApiService {
final String baseUrl;
final http.Client _client;
final Duration timeout;
TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized;
ApiService({
required this.baseUrl,
http.Client? client,
this.timeout = const Duration(seconds: 30),
}) : _client = client ?? http.Client();
void setTokenProvider(TokenProvider provider) {
_getToken = provider;
}
void setUnauthorizedHandler(UnauthorizedHandler handler) {
_onUnauthorized = handler;
}
void dispose() {
_client.close();
}
Map<String, String> _buildHeaders(Map<String, String>? extra) {
final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
final response = await _client
.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await _client
.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await _client
.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json',
}),
body: data, // http package handles form-encoding for Map<String, String>
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> put(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await _client
.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
}) async {
final response = await _client
.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
return _processResponse(response);
}
Map<String, String> _jsonHeaders(Map<String, String>? extra) {
return {'Content-Type': 'application/json', if (extra != null) ...extra};
}
Future<dynamic> _processResponse(http.Response res) async {
final body = _decodeBody(res);
if (res.statusCode >= 200 && res.statusCode < 300) {
return body;
}
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
throw Exception('API error ${res.statusCode}: $body');
}
dynamic _decodeBody(http.Response res) {
if (res.body.isEmpty) return null;
final contentType = res.headers['content-type'] ?? '';
final shouldTryJson = contentType.contains('application/json') ||
contentType.contains('+json') ||
res.body.trimLeft().startsWith('{') ||
res.body.trimLeft().startsWith('[');
if (!shouldTryJson) return res.body;
try {
return jsonDecode(res.body);
} catch (_) {
// Avoid turning a server-side error body into a client-side crash.
return res.body;
}
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/tokenStorageService.dart'; import 'package:mileograph_flutter/services/token_storage_service.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
final ApiService api; final ApiService api;
@@ -9,15 +9,18 @@ class AuthService extends ChangeNotifier {
final TokenStorageService _tokenStorage = TokenStorageService(); final TokenStorageService _tokenStorage = TokenStorageService();
AuthService({required this.api}); AuthService({required this.api}) {
api.setTokenProvider(() => token);
api.setUnauthorizedHandler(handleTokenExpired);
}
AuthenticatedUserData? _user; AuthenticatedUserData? _user;
bool get isLoggedIn => _user != null; bool get isLoggedIn => _user != null;
String? get token => _user?.access_token; String? get token => _user?.accessToken;
String? get userId => _user?.user_id; String? get userId => _user?.userId;
String? get username => _user?.username; String? get username => _user?.username;
String? get fullName => _user?.full_name; String? get fullName => _user?.fullName;
void setLoginData({ void setLoginData({
required String userId, required String userId,
@@ -27,10 +30,10 @@ class AuthService extends ChangeNotifier {
required String email, required String email,
}) { }) {
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
user_id: userId, userId: userId,
username: username, username: username,
full_name: fullName, fullName: fullName,
access_token: accessToken, accessToken: accessToken,
email: email, email: email,
); );
_persistToken(accessToken); _persistToken(accessToken);

View File

@@ -0,0 +1,2 @@
export 'data_service/data_service.dart';

View File

@@ -0,0 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
part 'data_service_core.dart';
part 'data_service_traction.dart';
part 'data_service_trips.dart';

View File

@@ -1,10 +1,4 @@
import 'dart:async'; part of 'data_service.dart';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; // assumes you've moved HomepageStats + submodels to a separate file
class _LegFetchOptions { class _LegFetchOptions {
final int limit; final int limit;
@@ -50,6 +44,10 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false;
bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading;
final Map<int, List<LocoAttrVersion>> _locoTimelines = {}; final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
final Map<int, bool> _isLocoTimelineLoading = {}; final Map<int, bool> _isLocoTimelineLoading = {};
List<LocoAttrVersion> timelineForLoco(int locoId) => List<LocoAttrVersion> timelineForLoco(int locoId) =>
@@ -76,6 +74,7 @@ class DataService extends ChangeNotifier {
// Station Data // Station Data
List<Station>? _cachedStations; List<Station>? _cachedStations;
DateTime? _stationsFetchedAt; DateTime? _stationsFetchedAt;
Future<List<Station>>? _stationsInFlight;
List<String> stations = [""]; List<String> stations = [""];
@@ -153,7 +152,8 @@ class DataService extends ChangeNotifier {
if (json is List) { if (json is List) {
final newLegs = json.map((e) => Leg.fromJson(e)).toList(); final newLegs = json.map((e) => Leg.fromJson(e)).toList();
_legs = append ? [..._legs, ...newLegs] : newLegs; _legs = append ? [..._legs, ...newLegs] : newLegs;
_legsHasMore = newLegs.length >= limit; // Keep "load more" available as long as the server returns items; hide only on empty.
_legsHasMore = newLegs.isNotEmpty;
} else { } else {
throw Exception('Unexpected legs response: $json'); throw Exception('Unexpected legs response: $json');
} }
@@ -177,103 +177,35 @@ class DataService extends ChangeNotifier {
); );
} }
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async { Future<List<Leg>> fetchLegsForLoco(
await fetchTraction( int locoId, {
hadOnly: true, bool includeNonPowering = false,
offset: offset,
limit: limit,
append: offset > 0,
);
}
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 50,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
bool append = false,
Map<String, dynamic>? filters,
}) async { }) async {
_isTractionLoading = true; if (locoId <= 0) return [];
final params =
includeNonPowering ? '?include_non_powering=true' : '';
try { try {
final params = StringBuffer('?limit=$limit&offset=$offset'); final json = await api.get('/legs/by-loco/$locoId$params');
if (hadOnly) params.write('&had_only=true'); dynamic list = json;
if (!mileageFirst) params.write('&mileage_first=false'); if (json is Map) {
for (final key in ['legs', 'data', 'results']) {
final payload = <String, dynamic>{}; if (json[key] is List) {
if (locoClass != null && locoClass.isNotEmpty) { list = json[key];
payload['class'] = locoClass; break;
}
}
} }
if (locoNumber != null && locoNumber.isNotEmpty) { if (list is List) {
payload['number'] = locoNumber; return list
.whereType<Map>()
.map((e) => Leg.fromJson(Map<String, dynamic>.from(e)))
.toList();
} }
if (filters != null) { debugPrint('Unexpected loco legs response: $json');
filters.forEach((key, value) {
if (value == null) return;
if (value is String && value.trim().isEmpty) return;
payload[key] = value;
});
}
final json = await api.post(
'/locos/search/v2${params.toString()}',
payload.isEmpty ? null : payload,
);
if (json is List) {
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
_traction = append ? [..._traction, ...newItems] : newItems;
_tractionHasMore = newItems.length >= limit - 1;
} else {
throw Exception('Unexpected traction response: $json');
}
} catch (e) {
debugPrint('Failed to fetch traction: $e');
if (!append) {
_traction = [];
}
_tractionHasMore = false;
} finally {
_isTractionLoading = false;
_notifyAsync();
}
}
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
} catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
return []; return [];
} finally {
_isLocoTimelineLoading[locoId] = false;
_notifyAsync();
}
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try {
final response = await api.put('/loco/new', payload);
final locoClass = payload['class']?.toString();
if (locoClass != null &&
locoClass.isNotEmpty &&
!_locoClasses.contains(locoClass)) {
_locoClasses = [..._locoClasses, locoClass];
}
_notifyAsync();
return response;
} catch (e) { } catch (e) {
debugPrint('Failed to create loco: $e'); debugPrint('Failed to fetch loco legs for $locoId: $e');
rethrow; return [];
} }
} }
@@ -282,69 +214,44 @@ class DataService extends ChangeNotifier {
final target = date ?? DateTime.now(); final target = date ?? DateTime.now();
final formatted = final formatted =
"${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}"; "${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}";
final endpoint = '/legs/on-this-day?date=$formatted';
dynamic json;
Object? lastError;
for (int attempt = 0; attempt < 2; attempt++) {
try {
json = await api.get(endpoint);
lastError = null;
break;
} catch (e) {
lastError = e;
if (!_looksLikeOnThisDayRoutingConflict(e)) break;
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
try { try {
final json = await api.get('/legs/on-this-day?date=$formatted');
if (json is List) { if (json is List) {
_onThisDay = json.map((e) => Leg.fromJson(e)).toList(); _onThisDay = json.map((e) => Leg.fromJson(e)).toList();
} else { } else {
_onThisDay = []; _onThisDay = [];
} }
} catch (e) { } catch (e) {
debugPrint('Failed to fetch on-this-day legs: $e'); lastError ??= e;
_onThisDay = []; _onThisDay = [];
} finally { } finally {
if (lastError != null) {
debugPrint('Failed to fetch on-this-day legs ($endpoint): $lastError');
}
_isOnThisDayLoading = false; _isOnThisDayLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<void> fetchTripDetails() async { bool _looksLikeOnThisDayRoutingConflict(Object error) {
_isTripDetailsLoading = true; final msg = error.toString();
try { return msg.contains('API error 422') &&
final json = await api.get('/trips/legs-and-stats'); msg.contains('[path, loco_id]') &&
if (json is List) { msg.contains('input: on-this-day');
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
} else {
_tripDetails = [];
}
} catch (e) {
debugPrint('Failed to fetch trip_map: $e');
_tripDetails = [];
} finally {
_isTripDetailsLoading = false;
_notifyAsync();
}
}
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
try {
final json = await api.get('/trips/stats/$tripId');
return _parseTripLocoStats(json);
} catch (e) {
debugPrint('Failed to fetch trip loco stats: $e');
return [];
}
}
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return [];
return list
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
} }
Future<void> fetchEventFields({bool force = false}) async { Future<void> fetchEventFields({bool force = false}) async {
@@ -394,54 +301,6 @@ class DataService extends ChangeNotifier {
return []; return [];
} }
Future<void> fetchTrips() async {
try {
final json = await api.get('/trips/mileage');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw != null) {
final tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
}
} catch (e) {
debugPrint('Failed to fetch trip list: $e');
_tripList = [];
} finally {
_notifyAsync();
}
}
Future<List<String>> fetchClassList() async {
if (_locoClasses.isNotEmpty) return _locoClasses;
try {
final json = await api.get('/loco/classlist');
if (json is List) {
_locoClasses = json.map((e) => e.toString()).toList();
_notifyAsync();
}
} catch (e) {
debugPrint('Failed to fetch class list: $e');
}
return _locoClasses;
}
Future<void> createLocoEvent({ Future<void> createLocoEvent({
required int locoId, required int locoId,
required String eventDate, required String eventDate,
@@ -466,6 +325,17 @@ class DataService extends ChangeNotifier {
} }
} }
Future<void> deleteTimelineBlock({
required int blockId,
}) async {
try {
await api.delete('/event/delete/$blockId');
} catch (e) {
debugPrint('Failed to delete timeline block $blockId: $e');
rethrow;
}
}
void clear() { void clear() {
_homepageStats = null; _homepageStats = null;
_legs = []; _legs = [];
@@ -475,6 +345,8 @@ class DataService extends ChangeNotifier {
_eventFields = []; _eventFields = [];
_locoTimelines.clear(); _locoTimelines.clear();
_isLocoTimelineLoading.clear(); _isLocoTimelineLoading.clear();
_latestLocoChanges = [];
_isLatestLocoChangesLoading = false;
_notifyAsync(); _notifyAsync();
} }
@@ -503,12 +375,27 @@ class DataService extends ChangeNotifier {
return _cachedStations!; return _cachedStations!;
} }
final response = await api.get('/location'); if (_stationsInFlight != null) return _stationsInFlight!;
final parsed = (response as List).map((e) => Station.fromJson(e)).toList();
_cachedStations = parsed; _stationsInFlight = () async {
_stationsFetchedAt = now; try {
final response = await api.get('/location');
if (response is! List) return const <Station>[];
final parsed = response
.whereType<Map>()
.map((e) => Station.fromJson(Map<String, dynamic>.from(e)))
.toList();
_cachedStations = parsed;
_stationsFetchedAt = now;
return parsed;
} catch (e) {
debugPrint('Failed to fetch stations: $e');
return const <Station>[];
} finally {
_stationsInFlight = null;
}
}();
return parsed; return _stationsInFlight!;
} }
} }

View File

@@ -0,0 +1,153 @@
part of 'data_service.dart';
extension DataServiceTraction on DataService {
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {
await fetchTraction(
hadOnly: true,
offset: offset,
limit: limit,
append: offset > 0,
);
}
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 50,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
bool append = false,
Map<String, dynamic>? filters,
}) async {
_isTractionLoading = true;
try {
final params = StringBuffer('?limit=$limit&offset=$offset');
if (hadOnly) params.write('&had_only=true');
if (!mileageFirst) params.write('&mileage_first=false');
final payload = <String, dynamic>{};
if (locoClass != null && locoClass.isNotEmpty) {
payload['class'] = locoClass;
}
if (locoNumber != null && locoNumber.isNotEmpty) {
payload['number'] = locoNumber;
}
if (filters != null) {
filters.forEach((key, value) {
if (value == null) return;
if (value is String && value.trim().isEmpty) return;
payload[key] = value;
});
}
final json = await api.post(
'/locos/search/v2${params.toString()}',
payload.isEmpty ? null : payload,
);
if (json is List) {
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
_traction = append ? [..._traction, ...newItems] : newItems;
_tractionHasMore = newItems.length >= limit - 1;
} else {
throw Exception('Unexpected traction response: $json');
}
} catch (e) {
debugPrint('Failed to fetch traction: $e');
if (!append) {
_traction = [];
}
_tractionHasMore = false;
} finally {
_isTractionLoading = false;
_notifyAsync();
}
}
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
} catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
return [];
} finally {
_isLocoTimelineLoading[locoId] = false;
_notifyAsync();
}
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try {
final response = await api.put('/loco/new', payload);
final locoClass = payload['class']?.toString();
if (locoClass != null &&
locoClass.isNotEmpty &&
!_locoClasses.contains(locoClass)) {
_locoClasses = [..._locoClasses, locoClass];
}
_notifyAsync();
return response;
} catch (e) {
debugPrint('Failed to create loco: $e');
rethrow;
}
}
Future<List<String>> fetchClassList() async {
if (_locoClasses.isNotEmpty) return _locoClasses;
try {
final json = await api.get('/loco/classlist');
if (json is List) {
_locoClasses = json.map((e) => e.toString()).toList();
_notifyAsync();
}
} catch (e) {
debugPrint('Failed to fetch class list: $e');
}
return _locoClasses;
}
Future<void> fetchLatestLocoChanges({int limit = 25, int offset = 0}) async {
_isLatestLocoChangesLoading = true;
_notifyAsync();
try {
final json =
await api.get('/loco/changes/latest?limit=$limit&offset=$offset');
dynamic results = json;
if (json is Map && json['data'] is List) {
results = json['data'];
}
if (results is List) {
final parsed = <LocoChange>[];
for (final item in results) {
if (item is Map<String, dynamic>) {
parsed.add(LocoChange.fromJson(item));
} else if (item is Map) {
parsed.add(
LocoChange.fromJson(
item.map((key, value) => MapEntry(key.toString(), value)),
),
);
}
}
_latestLocoChanges = parsed;
} else {
throw Exception('Unexpected latest loco changes response: $json');
}
} catch (e) {
debugPrint('Failed to fetch latest loco changes: $e');
_latestLocoChanges = [];
} finally {
_isLatestLocoChangesLoading = false;
_notifyAsync();
}
}
}

View File

@@ -0,0 +1,132 @@
part of 'data_service.dart';
extension DataServiceTrips on DataService {
Future<void> fetchTripDetails() async {
_isTripDetailsLoading = true;
try {
final json = await api.get('/trips/legs-and-stats');
if (json is List) {
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
} else {
_tripDetails = [];
}
} catch (e) {
debugPrint('Failed to fetch trip_map: $e');
_tripDetails = [];
} finally {
_isTripDetailsLoading = false;
_notifyAsync();
}
}
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
try {
final json = await api.get('/trips/stats/$tripId');
return _parseTripLocoStats(json);
} catch (e) {
debugPrint('Failed to fetch trip loco stats: $e');
return [];
}
}
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return [];
return list
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
}
Future<void> fetchTrips() async {
try {
final json = await api.get('/trips/mileage');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw != null) {
final tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
}
} catch (e) {
debugPrint('Failed to fetch trip list: $e');
_tripList = [];
} finally {
_notifyAsync();
}
}
Future<void> fetchTripOptions() async {
try {
final json = await api.get('/trips');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw != null) {
final tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
}
} catch (e) {
debugPrint('Failed to fetch trip list: $e');
_tripList = [];
} finally {
_notifyAsync();
}
}
void upsertTripSummary(TripSummary trip) {
final existingIndex =
_tripList.indexWhere((element) => element.tripId == trip.tripId);
if (existingIndex >= 0) {
_tripList[existingIndex] = trip;
} else {
_tripList = [trip, ..._tripList];
}
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
_notifyAsync();
}
}

View File

@@ -7,9 +7,10 @@ class NavigationGuard {
_callback = callback; _callback = callback;
} }
static void unregister(NavigationGuardCallback callback) { static void unregister([NavigationGuardCallback? callback]) {
if (_callback == callback) { if (callback == null || identical(_callback, callback)) {
_callback = null; _callback = null;
_promptActive = false;
} }
} }

393
lib/ui/app_shell.dart Normal file
View File

@@ -0,0 +1,393 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/login/login.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
import 'package:mileograph_flutter/components/pages/dashboard.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/components/pages/loco_legs.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
const List<String> _contentPages = [
"/",
"/calculator",
"/legs",
"/traction",
"/trips",
"/add",
];
const int _addTabIndex = 5;
class _NavItem {
final String label;
final IconData icon;
const _NavItem(this.label, this.icon);
}
const List<_NavItem> _navItems = [
_NavItem("Home", Icons.home),
_NavItem("Calculator", Icons.route),
_NavItem("Entries", Icons.list),
_NavItem("Traction", Icons.train),
_NavItem("Trips", Icons.book),
_NavItem("Add", Icons.add),
];
int tabIndexForPath(String path) {
final newIndex = _contentPages.indexWhere((routePath) {
if (path == routePath) return true;
if (routePath == '/') return path == '/';
return path.startsWith('$routePath/');
});
return newIndex < 0 ? 0 : newIndex;
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final GoRouter _router;
bool _routerInitialized = false;
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_routerInitialized) return;
_routerInitialized = true;
final auth = context.read<AuthService>();
_router = GoRouter(
refreshListenable: auth,
redirect: (context, state) {
final loggedIn = auth.isLoggedIn;
final loggingIn = state.uri.toString() == '/login';
if (!loggedIn && !loggingIn) return '/login';
if (loggedIn && loggingIn) return '/';
return null;
},
routes: [
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => MyHomePage(child: child),
routes: [
GoRoute(path: '/', builder: (context, state) => const Dashboard()),
GoRoute(
path: '/calculator',
builder: (context, state) => CalculatorPage(),
),
GoRoute(
path: '/calculator/details',
builder: (context, state) =>
CalculatorDetailsPage(result: state.extra),
),
GoRoute(path: '/legs', builder: (context, state) => LegsPage()),
GoRoute(
path: '/traction',
builder: (context, state) => TractionPage(),
),
GoRoute(
path: '/traction/:id/timeline',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final locoId = int.tryParse(idParam ?? '') ?? 0;
final extra = state.extra;
String label = state.uri.queryParameters['label'] ?? '';
if (extra is Map && extra['label'] is String) {
label = extra['label'] as String;
} else if (extra is String && extra.isNotEmpty) {
label = extra;
}
if (label.trim().isEmpty) label = 'Loco $locoId';
return LocoTimelinePage(locoId: locoId, locoLabel: label);
},
),
GoRoute(
path: '/traction/:id/legs',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final locoId = int.tryParse(idParam ?? '') ?? 0;
final extra = state.extra;
String label = state.uri.queryParameters['label'] ?? '';
if (extra is Map && extra['label'] is String) {
label = extra['label'] as String;
} else if (extra is String && extra.isNotEmpty) {
label = extra;
}
if (label.trim().isEmpty) label = 'Loco $locoId';
return LocoLegsPage(locoId: locoId, locoLabel: label);
},
),
GoRoute(
path: '/traction/new',
builder: (context, state) => const NewTractionPage(),
),
GoRoute(path: '/trips', builder: (context, state) => TripsPage()),
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final legId = idParam == null ? null : int.tryParse(idParam);
return NewEntryPage(editLegId: legId);
},
),
],
),
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
],
);
}
@override
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
return MaterialApp.router(
title: 'Mileograph',
routerConfig: _router,
theme: ThemeData(
useMaterial3: true,
colorScheme: lightDynamic ?? defaultLight,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark,
),
themeMode: ThemeMode.system,
);
},
);
}
}
class MyHomePage extends StatefulWidget {
final Widget child;
const MyHomePage({super.key, required this.child});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
List<String> get contentPages => _contentPages;
Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) {
return;
}
await NavigationGuard.attemptNavigation(() async {
if (!mounted) return;
context.go(contentPages[index]);
});
}
int? _lastTabIndex;
final List<int> _tabHistory = [];
bool _handlingBackNavigation = false;
bool _fetched = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_fetched) return;
_fetched = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future(() async {
if (!mounted) return;
final data = context.read<DataService>();
final auth = context.read<AuthService>();
await auth.tryRestoreSession();
if (!auth.isLoggedIn) return;
data.fetchEventFields();
if (data.homepageStats == null) {
data.fetchHomepageStats();
}
if (data.legs.isEmpty) {
data.fetchLegs();
}
if (data.traction.isEmpty) {
data.fetchHadTraction();
}
if (data.latestLocoChanges.isEmpty) {
data.fetchLatestLocoChanges();
}
if (data.onThisDay.isEmpty) {
data.fetchOnThisDay();
}
if (data.tripDetails.isEmpty) {
data.fetchTripDetails();
}
});
});
}
@override
Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri;
final pageIndex = tabIndexForPath(uri.path);
_recordTabChange(pageIndex);
if (pageIndex != _addTabIndex) {
NavigationGuard.unregister();
}
final homepageReady = context.select<DataService, bool>(
(data) => data.homepageStats != null || !data.isHomepageLoading,
);
final auth = context.read<AuthService>();
final currentPage = homepageReady
? widget.child
: const Center(child: CircularProgressIndicator());
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) {
shellNav.pop();
return;
}
if (_tabHistory.isNotEmpty) {
final previousTab = _tabHistory.removeLast();
if (!mounted) return;
_handlingBackNavigation = true;
context.go(contentPages[previousTab]);
return;
}
if (pageIndex != 0) {
if (!mounted) return;
_handlingBackNavigation = true;
context.go(contentPages[0]);
return;
}
SystemNavigator.pop();
},
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 900;
final railExtended = constraints.maxWidth >= 1400;
final navRailDestinations = _navItems
.map(
(item) => NavigationRailDestination(
icon: Icon(item.icon),
label: Text(item.label),
),
)
.toList();
final navBarDestinations = _navItems
.map(
(item) => NavigationDestination(
icon: Icon(item.icon),
label: item.label,
),
)
.toList();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text.rich(
TextSpan(
children: const [
TextSpan(text: "Mile"),
TextSpan(text: "O", style: TextStyle(color: Colors.red)),
TextSpan(text: "graph"),
],
style: const TextStyle(
decoration: TextDecoration.none,
color: Colors.white,
fontFamily: "Tomatoes",
),
),
),
actions: [
const IconButton(
onPressed: null,
icon: Icon(Icons.account_circle),
),
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
],
),
bottomNavigationBar: isWide
? null
: NavigationBar(
selectedIndex: pageIndex,
onDestinationSelected: (int index) =>
_onItemTapped(index, pageIndex),
destinations: navBarDestinations,
),
body: isWide
? Row(
children: [
SafeArea(
child: NavigationRail(
selectedIndex: pageIndex,
extended: railExtended,
labelType: railExtended
? NavigationRailLabelType.none
: NavigationRailLabelType.selected,
onDestinationSelected: (int index) =>
_onItemTapped(index, pageIndex),
destinations: navRailDestinations,
),
),
const VerticalDivider(width: 1),
Expanded(child: currentPage),
],
)
: currentPage,
);
},
),
);
}
void _recordTabChange(int pageIndex) {
final last = _lastTabIndex;
if (last == null) {
_lastTabIndex = pageIndex;
return;
}
if (last == pageIndex) return;
if (_handlingBackNavigation) {
_handlingBackNavigation = false;
_lastTabIndex = pageIndex;
return;
}
if (_tabHistory.isEmpty || _tabHistory.last != last) {
_tabHistory.add(last);
}
_lastTabIndex = pageIndex;
}
}

View File

@@ -66,7 +66,7 @@ packages:
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"

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.2.0+1 version: 0.3.0+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -36,6 +36,7 @@ dependencies:
provider: ^6.1.5 provider: ^6.1.5
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
# 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.

20
test/app_shell_test.dart Normal file
View File

@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mileograph_flutter/ui/app_shell.dart';
void main() {
test('tabIndexForPath maps nested routes', () {
expect(tabIndexForPath('/'), 0);
expect(tabIndexForPath('/calculator'), 1);
expect(tabIndexForPath('/calculator/details'), 1);
expect(tabIndexForPath('/legs'), 2);
expect(tabIndexForPath('/traction/12/timeline'), 3);
expect(tabIndexForPath('/trips'), 4);
expect(tabIndexForPath('/add'), 5);
});
test('tabIndexForPath ignores query when parsing uri', () {
expect(tabIndexForPath(Uri.parse('/trips?sort=desc').path), 4);
expect(tabIndexForPath(Uri.parse('/calculator/details?x=1').path), 1);
});
}

View File

@@ -1,30 +1,18 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mileograph_flutter/app.dart';
import 'package:mileograph_flutter/main.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { TestWidgetsFlutterBinding.ensureInitialized();
// Build our app and trigger a frame.
await tester.pumpWidget(MyApp());
// Verify that our counter starts at 0. setUp(() {
expect(find.text('0'), findsOneWidget); SharedPreferences.setMockInitialValues({});
expect(find.text('1'), findsNothing); });
// Tap the '+' icon and trigger a frame. testWidgets('Shows login UI when logged out', (WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.add)); await tester.pumpWidget(const App());
await tester.pump(); await tester.pump(const Duration(milliseconds: 100));
// Verify that our counter has incremented. expect(find.text('Login'), findsWidgets);
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
}); });
} }