6 Commits

Author SHA1 Message Date
44d79e7c28 Improve entries page and latest changes panel, units on events and timeline
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m3s
Release / android-build (push) Successful in 19m21s
Release / release-master (push) Successful in 40s
Release / release-dev (push) Successful in 42s
2025-12-23 17:41:21 +00:00
29959f7580 remove hero buttons
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m50s
Release / android-build (push) Successful in 16m21s
Release / release-dev (push) Successful in 33s
Release / release-master (push) Successful in 31s
2025-12-22 23:24:46 +00:00
d5d204dd19 add filter panel to calculator 2025-12-22 23:16:54 +00:00
950978b021 new settings panel for url pickup
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 7m42s
Release / android-build (push) Successful in 16m34s
Release / release-dev (push) Successful in 38s
Release / release-master (push) Successful in 37s
2025-12-22 22:45:33 +00:00
dc5ed2567f fix api endpoint
Some checks failed
Release / meta (push) Failing after 10s
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-22 21:39:06 +00:00
b1a8f7baf4 dashboard overhaul
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m15s
Release / android-build (push) Successful in 18m37s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 26s
2025-12-22 19:39:50 +00:00
28 changed files with 3011 additions and 879 deletions

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -12,8 +13,16 @@ class App extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider<ApiService>( ChangeNotifierProvider<EndpointService>(
create: (_) => ApiService(baseUrl: 'http://localhost:8000/api/v1'), create: (_) => EndpointService(),
),
ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl);
service.setBaseUrl(endpoint.baseUrl);
return service;
},
create: (_) => ApiService(baseUrl: EndpointService.defaultBaseUrl),
), ),
ChangeNotifierProvider<AuthService>( ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()), create: (context) => AuthService(api: context.read<ApiService>()),

View File

@@ -133,6 +133,11 @@ class RouteCalculator extends StatefulWidget {
class _RouteCalculatorState extends State<RouteCalculator> { class _RouteCalculatorState extends State<RouteCalculator> {
List<Station> allStations = []; List<Station> allStations = [];
List<String> _networks = [];
List<String> _countries = [];
List<String> _selectedNetworks = [];
List<String> _selectedCountries = [];
bool _loadingStations = false;
RouteResult? _routeResult; RouteResult? _routeResult;
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
@@ -150,14 +155,31 @@ class _RouteCalculatorState extends State<RouteCalculator> {
} }
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final result = await data.fetchStations(); await data.fetchStationFilters();
if (mounted) { if (!mounted) return;
setState(() => allStations = result); setState(() {
} _networks = data.stationNetworks;
_countries = data.stationCountryNetworks.keys.toList();
});
await _loadStations();
}); });
} }
} }
Future<void> _loadStations() async {
setState(() => _loadingStations = true);
final data = context.read<DataService>();
final stations = await data.fetchStations(
countries: _selectedCountries,
networks: _selectedNetworks,
);
if (!mounted) return;
setState(() {
allStations = stations;
_loadingStations = false;
});
}
Future<void> _calculateRoute(List<String> stations) async { Future<void> _calculateRoute(List<String> stations) async {
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
@@ -215,6 +237,43 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
return Column( return Column(
children: [ children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_MultiSelectFilter(
label: 'Countries',
options: _countries,
selected: _selectedCountries,
onChanged: (vals) {
setState(() => _selectedCountries = vals);
_loadStations();
},
),
_MultiSelectFilter(
label: 'Networks',
options: _networks,
selected: _selectedNetworks,
onChanged: (vals) {
setState(() => _selectedNetworks = vals);
_loadStations();
},
),
if (_loadingStations)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
Expanded( Expanded(
child: ReorderableListView( child: ReorderableListView(
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
@@ -300,32 +359,27 @@ class _RouteCalculatorState extends State<RouteCalculator> {
else else
SizedBox.shrink(), SizedBox.shrink(),
const SizedBox(height: 10), const SizedBox(height: 10),
LayoutBuilder( Padding(
builder: (context, constraints) { padding: const EdgeInsets.symmetric(horizontal: 16.0),
double screenWidth = constraints.maxWidth; child: Wrap(
alignment: WrapAlignment.center,
return Padding( spacing: 12,
padding: EdgeInsets.only(right: screenWidth < 450 ? 70 : 0), runSpacing: 8,
child: Row( children: [
mainAxisAlignment: MainAxisAlignment.center, ElevatedButton.icon(
children: [ icon: const Icon(Icons.add),
ElevatedButton.icon( label: const Text('Add Station'),
icon: const Icon(Icons.add), onPressed: _addStation,
label: const Text('Add Station'),
onPressed: _addStation,
),
const SizedBox(width: 16),
ElevatedButton.icon(
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
onPressed: () async {
await _calculateRoute(data.stations);
},
),
],
), ),
); ElevatedButton.icon(
}, icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
onPressed: () async {
await _calculateRoute(data.stations);
},
),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -350,3 +404,159 @@ Widget debugPanel(List<String> stations) {
), ),
); );
} }
class _MultiSelectFilter extends StatefulWidget {
const _MultiSelectFilter({
required this.label,
required this.options,
required this.selected,
required this.onChanged,
});
final String label;
final List<String> options;
final List<String> selected;
final ValueChanged<List<String>> onChanged;
@override
State<_MultiSelectFilter> createState() => _MultiSelectFilterState();
}
class _MultiSelectFilterState extends State<_MultiSelectFilter> {
late List<String> _tempSelected;
String _query = '';
@override
void initState() {
super.initState();
_tempSelected = List.from(widget.selected);
}
@override
void didUpdateWidget(covariant _MultiSelectFilter oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
_tempSelected = List.from(widget.selected);
}
}
void _openPicker() async {
_tempSelected = List.from(widget.selected);
_query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final filtered = widget.options
.where((opt) =>
_query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase()))
.toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select ${widget.label.toLowerCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton(
onPressed: () {
setModalState(() {
_tempSelected.clear();
});
Navigator.of(ctx).pop();
widget.onChanged(const []);
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
onChanged: (val) {
setModalState(() {
_query = val;
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final option = filtered[index];
final selected = _tempSelected.contains(option);
return CheckboxListTile(
value: selected,
title: Text(option),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!_tempSelected.contains(option)) {
_tempSelected.add(option);
}
} else {
_tempSelected.removeWhere((e) => e == option);
}
});
widget.onChanged(List.from(_tempSelected.toSet()));
},
);
},
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () {
widget.onChanged(List.from(_tempSelected.toSet()));
Navigator.of(ctx).pop();
},
icon: const Icon(Icons.check),
label: const Text('Apply'),
),
),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final hasSelection = widget.selected.isNotEmpty;
final display =
hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}';
return OutlinedButton.icon(
onPressed: _openPicker,
icon: const Icon(Icons.filter_alt),
label: SizedBox(
width: 180,
child: Text(
'${widget.label}: $display',
overflow: TextOverflow.ellipsis,
),
),
);
}
}

View File

@@ -36,16 +36,20 @@ class RouteDetailsView extends StatelessWidget {
final List<String> route; final List<String> route;
final List<double> costs; final List<double> costs;
final VoidCallback onBack; final VoidCallback onBack;
final Set<String> routingPoints;
const RouteDetailsView({ const RouteDetailsView({
super.key, super.key,
required this.route, required this.route,
required this.costs, required this.costs,
required this.onBack, required this.onBack,
this.routingPoints = const {},
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final highlightColor = Theme.of(context).colorScheme.primary;
final mutedColor = Theme.of(context).colorScheme.outlineVariant;
return Column( return Column(
children: [ children: [
Align( Align(
@@ -60,8 +64,20 @@ class RouteDetailsView extends StatelessWidget {
child: ListView.builder( child: ListView.builder(
itemCount: route.length, itemCount: route.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final label = route[index];
final isRoutingPoint = routingPoints.contains(label);
return ListTile( return ListTile(
title: Text(route[index]), leading: Icon(
Icons.circle,
size: 12,
color: isRoutingPoint ? highlightColor : mutedColor,
),
title: Text(
label,
style: isRoutingPoint
? TextStyle(color: highlightColor, fontWeight: FontWeight.w600)
: null,
),
trailing: Text("${costs[index].toStringAsFixed(2)} mi"), trailing: Text("${costs[index].toStringAsFixed(2)} mi"),
); );
}, },

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LatestLocoChangesPanel extends StatefulWidget { class LatestLocoChangesPanel extends StatefulWidget {
const LatestLocoChangesPanel({super.key}); const LatestLocoChangesPanel({super.key, this.expanded = false});
final bool expanded;
@override @override
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState(); State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
@@ -11,6 +15,9 @@ class LatestLocoChangesPanel extends StatefulWidget {
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> { class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
late final ScrollController _controller; late final ScrollController _controller;
final Set<String> _collapsedDates = {};
final Set<String> _collapsedClasses = {};
final Set<String> _collapsedLocos = {};
@override @override
void initState() { void initState() {
@@ -29,18 +36,35 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final changes = data.latestLocoChanges; final changes = data.latestLocoChanges;
final isLoading = data.isLatestLocoChangesLoading; final isLoading = data.isLatestLocoChangesLoading;
final hasMore = data.latestLocoChangesHasMore;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
'Latest loco changes', children: [
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), const Icon(Icons.bolt, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Latest loco changes',
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
if (isLoading && changes.isNotEmpty)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (isLoading && changes.isEmpty) if (isLoading && changes.isEmpty)
@@ -57,51 +81,419 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
), ),
) )
else else
SizedBox( Column(
height: 260, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Scrollbar( children: [
controller: _controller, _buildChangesList(changes, textTheme),
child: ListView.separated( const SizedBox(height: 8),
controller: _controller, Align(
itemCount: changes.length, alignment: Alignment.centerLeft,
separatorBuilder: (_, __) => const Divider(height: 1), child: OutlinedButton.icon(
itemBuilder: (context, index) { onPressed: isLoading ? null : _loadMore,
final change = changes[index]; icon: isLoading
return ListTile( ? const SizedBox(
dense: true, height: 14,
contentPadding: EdgeInsets.zero, width: 14,
title: Text( child: CircularProgressIndicator(strokeWidth: 2),
change.locoLabel, )
style: textTheme.titleSmall?.copyWith( : const Icon(Icons.expand_more),
fontWeight: FontWeight.w600, label: Text(isLoading ? 'Loading...' : 'Show more'),
), ),
),
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,
),
);
},
), ),
), ],
), ),
], ],
), ),
), ),
); );
} }
Widget _buildChangesList(List<LocoChange> changes, TextTheme textTheme) {
final grouped = _groupChanges(changes);
// Start with all locos collapsed by default.
if (_collapsedLocos.isEmpty) {
for (final group in grouped) {
for (final classGroup in group.classGroups) {
for (final locoGroup in classGroup.locoGroups) {
_collapsedLocos.add(
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel),
);
}
}
}
}
final listView = ListView.separated(
controller: null,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, groupIndex) {
final group = grouped[groupIndex];
final dateCollapsed = _collapsedDates.contains(group.dateLabel);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleDate(group.dateLabel),
icon: Icon(
dateCollapsed ? Icons.chevron_right : Icons.expand_more,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
group.dateLabel,
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: () => _collapseDateChildren(
group.dateLabel,
group.classGroups,
collapse: !_isDateFullyCollapsed(group),
),
child: Text(
_isDateFullyCollapsed(group) ? 'Expand all' : 'Collapse all',
),
),
],
),
),
if (!dateCollapsed)
...group.classGroups.map(
(classGroup) {
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
final classCollapsed = _collapsedClasses.contains(classKey);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleClass(classKey),
icon: Icon(
classCollapsed
? Icons.chevron_right
: Icons.expand_more,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
classGroup.classLabel,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: () => _collapseClassChildren(
group.dateLabel,
classGroup.classLabel,
classGroup.locoGroups,
collapse:
!_isClassFullyCollapsed(classKey, classGroup, group.dateLabel),
),
child: Text(
_isClassFullyCollapsed(classKey, classGroup, group.dateLabel)
? 'Expand all'
: 'Collapse all',
),
),
],
),
if (!classCollapsed)
...classGroup.locoGroups.map(
(locoGroup) {
final locoKey =
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel);
final locoCollapsed = _collapsedLocos.contains(locoKey);
return Padding(
padding:
const EdgeInsets.only(bottom: 4.0, left: 22.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleLoco(locoKey),
icon: Icon(
locoCollapsed
? Icons.chevron_right
: Icons.expand_more,
),
),
const SizedBox(width: 4),
Text(
locoGroup.locoLabel,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
if (!locoCollapsed) ...[
const SizedBox(height: 2),
...locoGroup.changes.map(
(change) => ListTile(
dense: true,
visualDensity: const VisualDensity(
horizontal: 0,
vertical: -3,
),
minVerticalPadding: 0,
contentPadding: EdgeInsets.zero,
title: Text(
'${change.changeLabel}: ${change.valueLabel}',
style: textTheme.bodyMedium,
),
trailing: change.approvedBy.isEmpty
? null
: Text(
change.approvedBy,
style: textTheme.labelSmall,
),
),
),
],
],
),
);
},
),
],
),
);
},
),
],
);
},
separatorBuilder: (_, __) => const Divider(height: 8),
itemCount: grouped.length,
);
if (widget.expanded) {
return listView;
}
return listView;
}
void _toggleDate(String date) {
setState(() {
if (_collapsedDates.contains(date)) {
_collapsedDates.remove(date);
} else {
_collapsedDates.add(date);
}
});
}
void _toggleClass(String key) {
setState(() {
if (_collapsedClasses.contains(key)) {
_collapsedClasses.remove(key);
} else {
_collapsedClasses.add(key);
}
});
}
void _toggleLoco(String key) {
setState(() {
if (_collapsedLocos.contains(key)) {
_collapsedLocos.remove(key);
} else {
_collapsedLocos.add(key);
}
});
}
void _collapseDateChildren(
String date,
List<_ClassGroup> classGroups, {
required bool collapse,
}) {
setState(() {
for (final classGroup in classGroups) {
final classKey = _classKey(date, classGroup.classLabel);
if (collapse) {
_collapsedClasses.add(classKey);
} else {
_collapsedClasses.remove(classKey);
}
for (final locoGroup in classGroup.locoGroups) {
final locoKey = _locoKey(date, classGroup.classLabel, locoGroup.locoLabel);
if (collapse) {
_collapsedLocos.add(locoKey);
} else {
_collapsedLocos.remove(locoKey);
}
}
}
});
}
void _collapseClassChildren(
String date,
String classLabel,
List<_LocoGroup> locos, {
required bool collapse,
}) {
setState(() {
final classKey = _classKey(date, classLabel);
if (collapse) {
_collapsedClasses.add(classKey);
} else {
_collapsedClasses.remove(classKey);
}
for (final locoGroup in locos) {
final locoKey = _locoKey(date, classLabel, locoGroup.locoLabel);
if (collapse) {
_collapsedLocos.add(locoKey);
} else {
_collapsedLocos.remove(locoKey);
}
}
});
}
bool _isDateFullyCollapsed(_ChangeGroup group) {
for (final classGroup in group.classGroups) {
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
if (!_collapsedClasses.contains(classKey)) return false;
for (final loco in classGroup.locoGroups) {
final locoKey = _locoKey(group.dateLabel, classGroup.classLabel, loco.locoLabel);
if (!_collapsedLocos.contains(locoKey)) return false;
}
}
return true;
}
bool _isClassFullyCollapsed(String classKey, _ClassGroup classGroup, String date) {
if (!_collapsedClasses.contains(classKey)) return false;
for (final loco in classGroup.locoGroups) {
final locoKey = _locoKey(date, classGroup.classLabel, loco.locoLabel);
if (!_collapsedLocos.contains(locoKey)) return false;
}
return true;
}
String _classKey(String date, String classLabel) => '$date|$classLabel';
String _locoKey(String date, String classLabel, String locoLabel) =>
'$date|$classLabel|$locoLabel';
List<_ChangeGroup> _groupChanges(List<LocoChange> changes) {
final dateFormat = DateFormat('yyyy-MM-dd');
final Map<String, Map<String, Map<String, List<LocoChange>>>> grouped = {};
final filtered = changes.where((change) {
final code = change.attrCode.toLowerCase();
return code != 'build_prec' && code != 'operational' && code != 'gettable';
});
for (final change in filtered) {
final date = change.approvedAt ?? change.validFrom;
final dateKey = date != null ? dateFormat.format(date) : 'Unknown date';
final classKey = change.locoClass.isNotEmpty
? change.locoClass
: 'Unknown class';
final locoKey = _locoLabel(change);
grouped.putIfAbsent(dateKey, () => {});
grouped[dateKey]!.putIfAbsent(classKey, () => {});
grouped[dateKey]![classKey]!.putIfAbsent(locoKey, () => []);
grouped[dateKey]![classKey]![locoKey]!.add(change);
}
final sortedDates = grouped.keys.toList()
..sort((a, b) {
if (a == 'Unknown date') return 1;
if (b == 'Unknown date') return -1;
return b.compareTo(a); // newest first
});
return sortedDates
.map(
(dateKey) => _ChangeGroup(
dateLabel: dateKey,
classGroups: grouped[dateKey]!.entries
.map(
(classEntry) => _ClassGroup(
classLabel: classEntry.key,
locoGroups: classEntry.value.entries
.map(
(locoEntry) => _LocoGroup(
locoLabel: locoEntry.key,
changes: locoEntry.value
..sort(
(a, b) => (b.approvedAt ?? b.validFrom ?? DateTime(0))
.compareTo(a.approvedAt ?? a.validFrom ?? DateTime(0)),
),
),
)
.toList(),
),
)
.toList(),
),
)
.toList();
}
Future<void> _loadMore() async {
final data = context.read<DataService>();
await data.fetchLatestLocoChanges(
offset: data.latestLocoChanges.length,
append: true,
);
}
}
class _ChangeGroup {
final String dateLabel;
final List<_ClassGroup> classGroups;
_ChangeGroup({required this.dateLabel, required this.classGroups});
}
class _LocoGroup {
final String locoLabel;
final List<LocoChange> changes;
_LocoGroup({required this.locoLabel, required this.changes});
}
class _ClassGroup {
final String classLabel;
final List<_LocoGroup> locoGroups;
_ClassGroup({required this.classLabel, required this.locoGroups});
}
String _locoLabel(LocoChange change) {
final number = change.locoNumber.trim();
final name = change.locoName.trim();
if (number.isNotEmpty && name.isNotEmpty) return '$number$name';
if (number.isNotEmpty) return number;
if (name.isNotEmpty) return name;
return 'Loco ${change.locoId}';
} }

View File

@@ -10,6 +10,7 @@ class LeaderboardPanel extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final leaderboard = data.homepageStats?.leaderboard ?? [];
final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (data.isHomepageLoading && leaderboard.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@@ -23,12 +24,32 @@ class LeaderboardPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
"Leaderboard", children: [
style: TextStyle( const Icon(Icons.emoji_events, size: 20),
fontSize: 18, const SizedBox(width: 8),
fontWeight: FontWeight.bold, Expanded(
), child: Text(
"Leaderboard",
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
if (leaderboard.isNotEmpty)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Top ${leaderboard.length}',
style: textTheme.labelSmall,
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
@@ -38,43 +59,38 @@ class LeaderboardPanel extends StatelessWidget {
) )
else else
Column( Column(
children: List.generate( children: [
leaderboard.length, for (int index = 0; index < leaderboard.length; index++) ...[
(index) { ListTile(
final leaderboardEntry = leaderboard[index]; contentPadding: EdgeInsets.zero,
return Container( dense: true,
width: double.infinity, leading: CircleAvatar(
margin: const EdgeInsets.symmetric( radius: 18,
horizontal: 0, vertical: 8), backgroundColor:
child: Padding( Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.symmetric(vertical: 6), child: Text(
child: Row( '${index + 1}',
mainAxisAlignment: MainAxisAlignment.spaceBetween, style: textTheme.labelLarge?.copyWith(
children: [ fontWeight: FontWeight.w800,
Text.rich( ),
TextSpan(
children: [
TextSpan(
text: '${index + 1}. ',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: leaderboardEntry.userFullName,
),
],
),
),
Text(
'${leaderboardEntry.mileage.toStringAsFixed(1)} mi',
),
],
), ),
), ),
); title: Text(
}, leaderboard[index].userFullName,
), style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
trailing: Text(
'${leaderboard[index].mileage.toStringAsFixed(1)} mi',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (index != leaderboard.length - 1) const Divider(height: 12),
],
],
), ),
], ],
), ),

View File

@@ -11,6 +11,7 @@ class TopTractionPanel extends StatelessWidget {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final locos = stats?.topLocos ?? []; final locos = stats?.topLocos ?? [];
final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && locos.isEmpty) { if (data.isHomepageLoading && locos.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@@ -24,12 +25,19 @@ class TopTractionPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
"Top Traction", children: [
style: TextStyle( const Icon(Icons.train, size: 20),
fontSize: 18, const SizedBox(width: 8),
fontWeight: FontWeight.bold, Expanded(
), child: Text(
"Top traction",
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (locos.isEmpty) if (locos.isEmpty)
@@ -39,52 +47,46 @@ class TopTractionPanel extends StatelessWidget {
) )
else else
Column( Column(
children: List.generate( children: [
locos.length, for (int index = 0; index < locos.length; index++) ...[
(index) { ListTile(
final loco = locos[index]; contentPadding: EdgeInsets.zero,
return Container( dense: true,
width: double.infinity, leading: CircleAvatar(
margin: radius: 18,
const EdgeInsets.symmetric(horizontal: 0, vertical: 8), backgroundColor:
child: Padding( Theme.of(context).colorScheme.primaryContainer,
padding: const EdgeInsets.symmetric(vertical: 6), child: Text(
child: Row( '${index + 1}',
mainAxisAlignment: MainAxisAlignment.spaceBetween, style: textTheme.labelLarge?.copyWith(
children: [ fontWeight: FontWeight.w800,
Column( ),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: '${index + 1}. ',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text:
'${loco.locoClass} ${loco.number}',
),
],
),
),
Text(
loco.name ?? '',
style:
const TextStyle(fontStyle: FontStyle.italic),
),
],
),
Text('${loco.mileage?.toStringAsFixed(1)} mi'),
],
), ),
), ),
); title: Text(
}, '${locos[index].locoClass} ${locos[index].number}',
), style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
subtitle: (locos[index].name ?? '').isEmpty
? null
: Text(
locos[index].name ?? '',
style: textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
trailing: Text(
'${locos[index].mileage?.toStringAsFixed(1)} mi',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (index != locos.length - 1) const Divider(height: 12),
],
],
), ),
], ],
), ),

View File

@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LegCard extends StatelessWidget { class LegCard extends StatefulWidget {
const LegCard({ const LegCard({
super.key, super.key,
required this.leg, required this.leg,
@@ -16,30 +18,106 @@ class LegCard extends StatelessWidget {
final bool showEditButton; final bool showEditButton;
final bool showDate; final bool showDate;
@override
State<LegCard> createState() => _LegCardState();
}
class _LegCardState extends State<LegCard> {
bool _expanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg;
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
child: ExpansionTile( child: ExpansionTile(
onExpansionChanged: (v) => setState(() => _expanded = v),
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const Icon(Icons.train), leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'), title: LayoutBuilder(
subtitle: Column( builder: (context, constraints) {
crossAxisAlignment: CrossAxisAlignment.start, final isWide = constraints.maxWidth > 520;
children: [ final routeText = Text('${leg.start}${leg.end}');
if (showDate) Text(_formatDateTime(leg.beginTime)), final timeText =
if (leg.headcode.isNotEmpty) Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
Text( if (!isWide) {
'Headcode: ${leg.headcode}', return routeText;
style: textTheme.labelSmall, }
), return Row(
if (leg.network.isNotEmpty) children: [
Text( timeText,
leg.network, const SizedBox(width: 6),
style: textTheme.labelSmall, const Text('·'),
), const SizedBox(width: 6),
], Expanded(child: routeText),
],
);
},
),
subtitle: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final timeWidget =
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap(
spacing: 8,
runSpacing: 4,
children: leg.locos.map((loco) {
final iconColor = loco.powering
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor;
final label = '${loco.locoClass} ${loco.number}'.trim();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.train, size: 14, color: iconColor),
const SizedBox(width: 4),
Text(
label.isEmpty ? 'Loco ${loco.id}' : label,
style: textTheme.labelSmall,
),
],
);
}).toList(),
)
: null;
final children = <Widget>[];
if (isWide) {
if (tractionWrap != null) {
children.add(tractionWrap);
}
} else {
children.add(timeWidget);
if (tractionWrap != null) {
children.add(const SizedBox(height: 4));
children.add(tractionWrap);
}
}
if (leg.headcode.isNotEmpty) {
children.add(
Text(
'Headcode: ${leg.headcode}',
style: textTheme.labelSmall,
),
);
}
if (leg.network.isNotEmpty) {
children.add(
Text(
leg.network,
style: textTheme.labelSmall,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -66,7 +144,7 @@ class LegCard extends StatelessWidget {
], ],
], ],
), ),
if (showEditButton) ...[ if (widget.showEditButton) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
tooltip: 'Edit entry', tooltip: 'Edit entry',
@@ -76,6 +154,18 @@ class LegCard extends StatelessWidget {
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => context.push('/legs/edit/${leg.id}'), onPressed: () => context.push('/legs/edit/${leg.id}'),
), ),
if (_expanded) ...[
const SizedBox(width: 4),
IconButton(
tooltip: 'Delete entry',
icon: const Icon(Icons.delete_outline),
color: Theme.of(context).colorScheme.error,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => _confirmDelete(context, leg.id),
),
],
], ],
], ],
), ),
@@ -114,15 +204,52 @@ class LegCard extends StatelessWidget {
); );
} }
Future<void> _confirmDelete(BuildContext context, int legId) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete entry?'),
content: const Text('Are you sure you want to delete this entry?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
try {
await data.api.delete('/legs/delete?leg_id=$legId');
await data.refreshLegs();
messenger.showSnackBar(const SnackBar(content: Text('Entry deleted')));
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete entry: $e')),
);
}
}
String _formatDate(DateTime? date) { String _formatDate(DateTime? date) {
if (date == null) return ''; if (date == null) return '';
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) { String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
String _formatDateTime(DateTime date, {bool includeDate = true}) {
final timeStr = _formatTime(date);
if (!includeDate) return timeStr;
final dateStr = _formatDate(date); final dateStr = _formatDate(date);
final timeStr =
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return '$dateStr · $timeStr'; return '$dateStr · $timeStr';
} }
@@ -140,7 +267,7 @@ class LegCard extends StatelessWidget {
: textTheme.bodyMedium?.copyWith(color: theme.disabledColor); : textTheme.bodyMedium?.copyWith(color: theme.disabledColor);
final background = powering final background = powering
? theme.colorScheme.surfaceContainerHighest ? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceVariant; : theme.colorScheme.surfaceContainerLow;
return Chip( return Chip(
label: Text( label: Text(
'${loco.locoClass} ${loco.number}', '${loco.locoClass} ${loco.number}',

View File

@@ -81,6 +81,12 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
const LoginPanel(), const LoginPanel(),
const SizedBox(height: 16),
IconButton(
icon: const Icon(Icons.settings, color: Colors.grey),
tooltip: 'Settings',
onPressed: () => context.go('/settings'),
),
], ],
), ),
), ),

View File

@@ -39,9 +39,9 @@ class CalculatorDetailsPage extends StatelessWidget {
child: RouteDetailsView( child: RouteDetailsView(
route: parsed.calculatedRoute, route: parsed.calculatedRoute,
costs: parsed.costs, costs: parsed.costs,
routingPoints: parsed.inputRoute.toSet(),
onBack: () => context.pop(), onBack: () => context.pop(),
), ),
); );
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart'; import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
@@ -38,41 +39,16 @@ class _DashboardState extends State<Dashboard> {
}, },
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 1100; const spacing = 16.0;
final metricChips = _buildMetricChips( final maxWidth = constraints.maxWidth;
context,
totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(),
legCount: stats?.legCount ?? data.trips.length,
);
return Stack( return Stack(
children: [ children: [
ListView( ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildHeader(context, auth, stats, data.isHomepageLoading), _buildHero(context, auth, data, stats),
const SizedBox(height: 12), const SizedBox(height: spacing),
Wrap(spacing: 12, runSpacing: 12, children: metricChips), _buildTiles(context, data, maxWidth, spacing),
const SizedBox(height: 16),
isWide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildMainColumn(context, data)),
const SizedBox(width: 16),
SizedBox(
width: 360,
child: _buildSidebar(context, data),
),
],
)
: Column(
children: [
_buildMainColumn(context, data),
const SizedBox(height: 16),
_buildSidebar(context, data),
],
),
], ],
), ),
if (isInitialLoading) if (isInitialLoading)
@@ -100,139 +76,387 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildHeader( Widget _buildHero(
BuildContext context, BuildContext context,
AuthService auth, AuthService auth,
DataService data,
HomepageStats? stats, HomepageStats? stats,
bool loading,
) { ) {
final colorScheme = Theme.of(context).colorScheme;
final greetingName = final greetingName =
stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there'; stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
return Row( final totalMileage = stats?.totalMileage ?? 0;
mainAxisAlignment: MainAxisAlignment.spaceBetween, final currentYearMileage = data.getMileageForCurrentYear();
crossAxisAlignment: CrossAxisAlignment.start, final legCount = stats?.legCount ?? data.trips.length;
children: [ final progress = totalMileage == 0
Column( ? 0.0
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
return Card(
clipBehavior: Clip.antiAlias,
elevation: 2,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer,
colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium), _heroHeading(context, greetingName, colorScheme),
const SizedBox(height: 2), const SizedBox(height: 18),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_metricTile(
context,
label: 'Total mileage',
value: '${totalMileage.toStringAsFixed(1)} mi',
icon: Icons.route,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
context,
label: 'This year',
value: '${currentYearMileage.toStringAsFixed(1)} mi',
icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
context,
label: 'Entries logged',
value: legCount.toString(),
icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer,
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LinearProgressIndicator(
value: progress.isNaN ? 0 : progress,
minHeight: 10,
backgroundColor: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 6),
Text( Text(
'Welcome back, $greetingName', totalMileage == 0
style: Theme.of(context).textTheme.headlineSmall, ? 'Log a new entry to start your timeline.'
: 'Year-to-date is ${(progress * 100).toStringAsFixed(0)}% of all mileage.',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
), ),
], ],
), ),
if (loading) ),
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
); );
} }
List<Widget> _buildMetricChips( Widget _metricTile(
BuildContext context, { BuildContext context, {
required double totalMileage, required String label,
required double currentYearMileage, required String value,
required int legCount, required IconData icon,
required Color color,
}) { }) {
final textTheme = Theme.of(context).textTheme; final bg = Colors.white.withValues(alpha: 0.14);
Widget metricCard(String label, String value) { return Container(
return Card( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
elevation: 1, decoration: BoxDecoration(
child: Padding( color: bg,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), borderRadius: BorderRadius.circular(12),
child: Column( border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
label.toUpperCase(), label,
style: textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 0.7, color: color.withValues(alpha: 0.85),
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7), letterSpacing: 0.4,
), ),
), ),
const SizedBox(height: 4),
Text( Text(
value, value,
style: textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
color: color,
), ),
), ),
], ],
), ),
), ],
); ),
}
return [
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
metricCard('Entries logged', legCount.toString()),
];
}
Widget _buildMainColumn(BuildContext context, DataService data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCard(
context,
title: 'On this day',
action:
data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year)
.length >
5
? TextButton(
onPressed: () => setState(() {
_showAllOnThisDay = !_showAllOnThisDay;
}),
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
)
: null,
trailing: data.isOnThisDayLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
child: _buildLegList(
context,
data.onThisDay,
showAll: _showAllOnThisDay,
emptyMessage: 'No historical moves for today yet.',
),
),
const SizedBox(height: 12),
_buildTripsCard(context, data),
],
); );
} }
Widget _buildSidebar(BuildContext context, DataService data) { Widget _buildTiles(
BuildContext context,
DataService data,
double maxWidth,
double spacing,
) {
final isWide = maxWidth >= 1200;
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildOnThisDayCard(context, data),
const SizedBox(height: 16),
_buildTripsCard(context, data),
const SizedBox(height: 16),
const LatestLocoChangesPanel(expanded: true),
],
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
TopTractionPanel(),
SizedBox(height: 16),
LeaderboardPanel(),
],
),
),
],
);
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildOnThisDayCard(context, data),
const SizedBox(height: 16),
const TopTractionPanel(), const TopTractionPanel(),
const SizedBox(height: 12), const SizedBox(height: 16),
const LeaderboardPanel(), const LeaderboardPanel(),
const SizedBox(height: 12), const SizedBox(height: 16),
_buildTripsCard(context, data),
const SizedBox(height: 16),
const LatestLocoChangesPanel(), const LatestLocoChangesPanel(),
], ],
); );
} }
Widget _buildCard( Widget _heroHeading(
BuildContext context,
String greetingName,
ColorScheme colorScheme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dashboard',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onPrimaryContainer,
letterSpacing: 0.4,
),
),
const SizedBox(height: 2),
Text(
'Welcome back, $greetingName',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
),
),
],
);
}
Widget _buildOnThisDayCard(BuildContext context, DataService data) {
final filtered = data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year)
.toList();
final textTheme = Theme.of(context).textTheme;
final showMore = filtered.length > 5;
final visible = _showAllOnThisDay ? filtered : filtered.take(6).toList();
return _panel(
context,
icon: Icons.history_toggle_off,
title: 'On this day',
trailing: data.isOnThisDayLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
action: showMore
? TextButton(
onPressed: () =>
setState(() => _showAllOnThisDay = !_showAllOnThisDay),
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
)
: null,
child: filtered.isEmpty
? Text(
'No historical moves for today yet.',
style: textTheme.bodyMedium,
)
: Column(
children: [
for (int idx = 0; idx < visible.length; idx++) ...[
_otdRow(context, visible[idx], textTheme),
if (idx != visible.length - 1) const Divider(height: 12),
],
],
),
);
}
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme) {
final traction = leg.locos;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 64,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
leg.beginTime.year.toString(),
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(_formatTime(leg.beginTime), style: textTheme.labelSmall),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${leg.start}${leg.end}',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (leg.headcode.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Text(
leg.headcode,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(
alpha: 0.7,
),
),
),
],
),
],
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 6),
Expanded(
child: traction.isEmpty
? Text(
'No traction recorded',
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(
alpha: 0.7,
),
),
)
: Wrap(
spacing: 8,
runSpacing: 4,
children: traction.map((loco) {
final iconColor = loco.powering
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor;
final label = '${loco.locoClass} ${loco.number}';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.train, size: 14, color: iconColor),
const SizedBox(width: 4),
Text(
label,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color
?.withValues(alpha: 0.85),
),
),
],
);
}).toList(),
),
),
],
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
],
);
}
Widget _panel(
BuildContext context, { BuildContext context, {
required IconData icon,
required String title, required String title,
required Widget child, required Widget child,
Widget? trailing, Widget? trailing,
@@ -241,29 +465,29 @@ class _DashboardState extends State<Dashboard> {
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Icon(icon, size: 20),
title, const SizedBox(width: 8),
style: Theme.of(context).textTheme.titleMedium?.copyWith( Expanded(
fontWeight: FontWeight.w700, child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
), ),
), ),
Row( if (action != null)
mainAxisSize: MainAxisSize.min, Padding(
children: [ padding: const EdgeInsets.only(right: 8.0),
if (action != null) action, child: action,
if (trailing != null) ...[ ),
const SizedBox(width: 8), if (trailing != null) trailing,
trailing,
],
],
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -274,56 +498,15 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildLegList(
BuildContext context,
List<Leg> legs, {
required String emptyMessage,
bool showAll = false,
}) {
final filtered = legs
.where((leg) => leg.beginTime.year != DateTime.now().year)
.toList();
if (filtered.isEmpty) {
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
}
final toShow = showAll ? filtered : filtered.take(5).toList();
return Column(
children: toShow.map((leg) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.train),
),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${leg.mileage.toStringAsFixed(1)} mi'),
if (leg.headcode.isNotEmpty)
Text(
leg.headcode,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).hintColor,
),
),
],
),
);
}).toList(),
);
}
Widget _buildTripsCard(BuildContext context, DataService data) { Widget _buildTripsCard(BuildContext context, DataService data) {
final tripsUnsorted = data.trips; final tripsUnsorted = data.trips;
List trips = []; List trips = [];
if (tripsUnsorted.isNotEmpty) { if (tripsUnsorted.isNotEmpty) {
trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId)); trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId));
} }
return _buildCard( return _panel(
context, context,
icon: Icons.bookmark,
title: 'Trips', title: 'Trips',
action: TextButton( action: TextButton(
onPressed: () => context.push('/trips'), onPressed: () => context.push('/trips'),
@@ -335,19 +518,46 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
) )
: Column( : Column(
children: trips.take(5).map((trip) { children: trips.take(6).map((trip) {
return ListTile( return Padding(
contentPadding: EdgeInsets.zero, padding: const EdgeInsets.symmetric(vertical: 6.0),
title: Text(trip.tripName), child: Row(
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.book, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trip.tripName,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
Text(
'${trip.tripMileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
],
),
); );
}).toList(), }).toList(),
), ),
); );
} }
String _formatDate(DateTime? dt) { String _formatTime(DateTime date) {
if (dt == null) return ''; return DateFormat('HH:mm').format(date);
return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} }
} }

View File

@@ -246,7 +246,8 @@ class _LegsPageState extends State<LegsPage> {
final dayLegs = <Leg>[]; final dayLegs = <Leg>[];
void flushDay() { void flushDay() {
if (currentDate == null) return; final date = currentDate;
if (date == null) return;
widgets.add( widgets.add(
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -254,7 +255,7 @@ class _LegsPageState extends State<LegsPage> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
currentDate!, date,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -36,6 +36,21 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} }
dynamic _normalizeFieldValue(_FieldEntry field) {
final name = field.field.name.toLowerCase();
final val = field.value;
if (name == 'max_speed') {
final numVal = val is num ? val.toDouble() : double.tryParse('$val');
if (numVal == null) return val;
final unit = (field.unit ?? 'kph').toLowerCase();
if (unit == 'mph') {
return numVal * 1.60934;
}
return numVal;
}
return val;
}
@override @override
void dispose() { void dispose() {
_disposeDrafts(_draftEvents); _disposeDrafts(_draftEvents);
@@ -57,7 +72,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
String? _eventDateForEntry(LocoAttrVersion entry) { String? _eventDateForEntry(LocoAttrVersion entry) {
final masked = entry.maskedValidFrom?.trim(); final masked = entry.maskedValidFrom?.trim();
if (masked != null && masked.isNotEmpty) return masked; if (masked != null && masked.isNotEmpty) return masked;
final from = entry.validFrom ?? entry.txnFrom; final from = entry.validFrom;
if (from == null) return null; if (from == null) return null;
return DateFormat('yyyy-MM-dd').format(from); return DateFormat('yyyy-MM-dd').format(from);
} }
@@ -115,7 +130,8 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
draft.details = ''; draft.details = '';
draft.fields.add( draft.fields.add(
_FieldEntry(field: field) _FieldEntry(field: field)
..value = _valueForEntry(entry), ..value = _valueForEntry(entry)
..unit = _guessUnit(field, entry.valueLabel),
); );
setState(() { setState(() {
@@ -123,6 +139,16 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
}); });
} }
String? _guessUnit(EventField field, String valueLabel) {
final name = field.name.toLowerCase();
if (name == 'max_speed') {
final val = valueLabel.toLowerCase();
if (val.contains('mph')) return 'mph';
return 'kph';
}
return _defaultUnitForField(field);
}
Future<void> _deleteEntry(LocoAttrVersion entry) async { Future<void> _deleteEntry(LocoAttrVersion entry) async {
if (_isDeleting) return; if (_isDeleting) return;
final blockId = entry.versionId; final blockId = entry.versionId;
@@ -241,7 +267,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
invalid.add('Field ${field.field.display} is empty'); invalid.add('Field ${field.field.display} is empty');
break; break;
} }
values[field.field.name] = val; values[field.field.name] = _normalizeFieldValue(field);
} }
if (invalid.isNotEmpty) continue; if (invalid.isNotEmpty) continue;
if (values.isEmpty) { if (values.isEmpty) {

View File

@@ -184,7 +184,9 @@ class _FieldList extends StatelessWidget {
value: null, value: null,
onChanged: (field) { onChanged: (field) {
if (field == null) return; if (field == null) return;
draft.fields.add(_FieldEntry(field: field)); draft.fields.add(
_FieldEntry(field: field)..unit = _defaultUnitForField(field),
);
onChange(); onChange();
}, },
items: availableFields items: availableFields
@@ -224,10 +226,10 @@ class _FieldList extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
_FieldInput( _FieldInput(
field: field.field, entry: field,
value: field.value, onChanged: (val, {String? unit}) {
onChanged: (val) {
field.value = val; field.value = val;
if (unit != null) field.unit = unit;
onChange(); onChange();
}, },
), ),
@@ -253,17 +255,18 @@ class _FieldList extends StatelessWidget {
class _FieldInput extends StatelessWidget { class _FieldInput extends StatelessWidget {
const _FieldInput({ const _FieldInput({
required this.field, required this.entry,
required this.value,
required this.onChanged, required this.onChanged,
}); });
final EventField field; final _FieldEntry entry;
final dynamic value; final void Function(dynamic value, {String? unit}) onChanged;
final ValueChanged<dynamic> onChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final field = entry.field;
final value = entry.value;
if (field.enumValues != null && field.enumValues!.isNotEmpty) { if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!; final options = field.enumValues!;
return DropdownButtonFormField<String>( return DropdownButtonFormField<String>(
@@ -293,6 +296,119 @@ class _FieldInput extends StatelessWidget {
); );
} }
final name = field.name.toLowerCase();
if (name == 'max_speed') {
final unit = entry.unit ?? 'kph';
final isNumber = true;
return Row(
children: [
Expanded(
child: TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(isNumber ? parsed : val, unit: unit);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kph/mph',
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
SizedBox(
width: 88,
child: DropdownButtonFormField<String>(
value: unit,
items: const [
DropdownMenuItem(value: 'kph', child: Text('kph')),
DropdownMenuItem(value: 'mph', child: Text('mph')),
],
onChanged: (val) {
if (val == null) return;
onChanged(value, unit: val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Unit',
),
),
),
],
);
}
if ({
'height',
'length',
'width',
'track_gauge',
}.contains(name)) {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'mm',
),
keyboardType: TextInputType.number,
);
}
if (name == 'weight') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'tonnes',
),
keyboardType: TextInputType.number,
);
}
if (name == 'power') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kW',
),
keyboardType: TextInputType.number,
);
}
if (name == 'tractive_effort') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kN',
),
keyboardType: TextInputType.number,
);
}
final isNumber = type == 'int' || type == 'integer'; final isNumber = type == 'int' || type == 'integer';
return TextFormField( return TextFormField(
initialValue: value?.toString(), initialValue: value?.toString(),
@@ -326,6 +442,13 @@ class _EventDraft {
class _FieldEntry { class _FieldEntry {
final EventField field; final EventField field;
dynamic value; dynamic value;
String? unit;
_FieldEntry({required this.field}); _FieldEntry({required this.field});
} }
String? _defaultUnitForField(EventField field) {
final name = field.name.toLowerCase();
if (name == 'max_speed') return 'kph';
return null;
}

View File

@@ -445,7 +445,7 @@ class _ValueBlockMenu extends StatelessWidget {
Future<void> showContextMenuAt(Offset globalPosition) async { Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context); final overlay = Overlay.of(context);
final renderBox = overlay?.context.findRenderObject() as RenderBox?; final renderBox = overlay.context.findRenderObject() as RenderBox?;
if (renderBox == null) return; if (renderBox == null) return;
// Translate from global screen coordinates into the overlay's local space // Translate from global screen coordinates into the overlay's local space
// so the menu appears where the gesture happened. // so the menu appears where the gesture happened.
@@ -577,7 +577,7 @@ class _TimelineModel {
_ValueSegment( _ValueSegment(
start: start, start: start,
end: end, end: end,
value: entry.valueLabel, value: _formatValueWithUnits(entry),
entry: entry, entry: entry,
), ),
); );
@@ -680,6 +680,53 @@ class _TimelineModel {
} }
} }
String _formatValueWithUnits(LocoAttrVersion entry) {
final raw = entry.valueLabel;
final code = entry.attrCode.toLowerCase();
final lowerRaw = raw.toLowerCase();
// Avoid double-appending if units already present.
final hasUnits = lowerRaw.contains('mm') ||
lowerRaw.contains('tonne') ||
lowerRaw.contains('kph') ||
lowerRaw.contains('mph');
double? asNumber = double.tryParse(raw);
String formatNumber(double value) {
if (value % 1 == 0) return value.toStringAsFixed(0);
return value.toStringAsFixed(2);
}
switch (code) {
case 'height':
case 'length':
case 'width':
case 'track_gauge':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} mm' : '$raw mm';
case 'weight':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} tonnes' : '$raw tonnes';
case 'power':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} kW' : '$raw kW';
case 'tractive_effort':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} kN' : '$raw kN';
case 'max_speed':
if (hasUnits) return raw;
if (asNumber != null) {
// Stored as kph.
final formatted = asNumber % 1 == 0
? asNumber.toStringAsFixed(0)
: asNumber.toStringAsFixed(1);
return '$formatted kph';
}
return '$raw kph';
default:
return raw;
}
}
class _AxisSegment { class _AxisSegment {
final DateTime start; final DateTime start;
final DateTime end; final DateTime end;
@@ -742,7 +789,15 @@ class _RowCell {
color: Colors.transparent, color: Colors.transparent,
); );
} }
final displayStart = _formatDate(seg.start) ?? ''; final entry = seg.entry;
String displayStart = '';
if (entry != null) {
if ((entry.maskedValidFrom ?? '').trim().isNotEmpty) {
displayStart = entry.maskedValidFrom!.trim();
} else if (entry.validFrom != null) {
displayStart = _formatDate(entry.validFrom) ?? '';
}
}
return _RowCell( return _RowCell(
value: seg.value, value: seg.value,
rangeLabel: displayStart, rangeLabel: displayStart,

View File

@@ -128,7 +128,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
); );
_lastSubmittedSnapshot = snapshot; _lastSubmittedSnapshot = snapshot;
_activeDraftId = null; _activeDraftId = null;
} catch (e) { } catch (e, st) {
debugPrint('Leg submit/update failed: $e');
debugPrintStack(stackTrace: st);
if (!mounted) return; if (!mounted) return;
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to submit: $e')), SnackBar(content: Text('Failed to submit: $e')),

View File

@@ -73,6 +73,8 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
for (var i = 0; i < _tractionItems.length; i++) { for (var i = 0; i < _tractionItems.length; i++) {
final item = _tractionItems[i]; final item = _tractionItems[i];
if (item.isMarker || item.loco == null) continue; if (item.isMarker || item.loco == null) continue;
final locoId = item.loco!.id;
if (locoId == 0) continue;
int allocPos; int allocPos;
if (i > markerIndex) { if (i > markerIndex) {
allocPos = -(i - markerIndex); allocPos = -(i - markerIndex);
@@ -80,8 +82,7 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
allocPos = (markerIndex - 1) - i; allocPos = (markerIndex - 1) - i;
} }
payload.add({ payload.add({
"loco_type": item.loco!.type, "loco_id": locoId,
"loco_number": item.loco!.number,
"alloc_pos": allocPos, "alloc_pos": allocPos,
"alloc_powering": item.powering ? 1 : 0, "alloc_powering": item.powering ? 1 : 0,
}); });

View File

@@ -0,0 +1,213 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController;
bool _saving = false;
@override
void initState() {
super.initState();
final endpoint = context.read<EndpointService>().baseUrl;
_endpointController = TextEditingController(text: endpoint);
}
@override
void dispose() {
_endpointController.dispose();
super.dispose();
}
Future<String?> _probeVersion(String url) async {
try {
var uri = Uri.parse(url.trim());
if (uri.scheme.isEmpty) {
uri = Uri.parse('https://$url');
}
// Probe the provided API endpoint as-is.
final target = uri;
final res = await http.get(target).timeout(const Duration(seconds: 10));
debugPrint(
'Endpoint probe ${target.toString()} -> ${res.statusCode} ${res.body}',
);
if (res.statusCode < 200 || res.statusCode >= 300) return null;
final body = res.body.trim();
debugPrint('Endpoint probe body: $body');
// Try JSON first
String? version;
try {
final parsed = jsonDecode(body);
debugPrint('Endpoint probe parsed: $parsed');
if (parsed is Map && parsed['version'] is String) {
version = parsed['version'] as String;
} else if (parsed is String) {
final candidate = parsed.trim().replaceAll('"', '');
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(candidate)) {
version = candidate;
}
}
} catch (_) {
// fall back to raw body parsing
}
version ??= body.split(RegExp(r'\s+')).firstWhere(
(part) => RegExp(r'^\d+\.\d+\.\d+$').hasMatch(part),
orElse: () => '',
);
if (version.isEmpty) return null;
final isValid = RegExp(r'^\d+\.\d+\.\d+$').hasMatch(version);
return isValid ? version : null;
} catch (_) {
return null;
}
}
Future<void> _save() async {
final endpointService = context.read<EndpointService>();
final dataService = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
final value = _endpointController.text.trim();
if (value.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('Please enter an endpoint URL.')),
);
return;
}
setState(() => _saving = true);
try {
final version = await _probeVersion(value);
if (version == null) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Endpoint test failed: no valid version returned.'),
),
);
}
return;
}
await endpointService.setBaseUrl(value);
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Endpoint set to "$value" ($version)')),
);
await Future.wait([
dataService.fetchHomepageStats(),
dataService.fetchOnThisDay(),
dataService.fetchTrips(),
dataService.fetchHadTraction(),
dataService.fetchLatestLocoChanges(),
dataService.fetchLegs(),
]);
}
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to save endpoint: $e')),
);
}
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
@override
Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>();
if (!endpointService.isLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
} else {
context.go('/');
}
},
),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Set the base URL for the Mileograph API. Leave blank to use the default.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
TextField(
controller: _endpointController,
decoration: const InputDecoration(
labelText: 'Endpoint URL',
hintText: 'https://mileograph.co.uk/api/v1',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Save endpoint'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _saving
? null
: () {
_endpointController.text =
EndpointService.defaultBaseUrl;
},
child: const Text('Reset to default'),
),
],
),
const SizedBox(height: 12),
Text(
'Current: ${endpointService.baseUrl}',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

File diff suppressed because it is too large Load Diff

View File

@@ -268,6 +268,7 @@ class _TripsPageState extends State<TripsPage> {
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
bool renaming = false; bool renaming = false;
bool deleting = false;
String tripName = trip.name; String tripName = trip.name;
return StatefulBuilder( return StatefulBuilder(
builder: (sheetCtx, setSheetState) { builder: (sheetCtx, setSheetState) {
@@ -285,6 +286,58 @@ class _TripsPageState extends State<TripsPage> {
} }
} }
Future<void> handleDelete() async {
if (deleting || trip.legs.isNotEmpty) return;
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(sheetCtx);
final navigator = Navigator.of(sheetCtx);
final ok = await showDialog<bool>(
context: sheetCtx,
builder: (ctx) {
return AlertDialog(
title: const Text('Delete trip?'),
content: Text(
'This will delete "${trip.name}". This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (ok != true || !mounted) return;
setSheetState(() => deleting = true);
try {
await api.delete('/trips/delete/${trip.id}');
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Deleted "${trip.name}"')),
);
navigator.pop();
} catch (e) {
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to delete trip: $e')),
);
} finally {
if (mounted) setSheetState(() => deleting = false);
}
}
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -317,6 +370,21 @@ class _TripsPageState extends State<TripsPage> {
tooltip: 'Rename trip', tooltip: 'Rename trip',
onPressed: renaming ? null : handleRename, onPressed: renaming ? null : handleRename,
), ),
if (trip.legs.isEmpty) ...[
const SizedBox(width: 4),
IconButton(
icon: deleting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_outline),
tooltip: 'Delete trip',
onPressed: deleting ? null : handleDelete,
color: Theme.of(context).colorScheme.error,
),
],
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text('${trip.mileage.toStringAsFixed(1)} mi'),
], ],

View File

@@ -82,39 +82,68 @@ class TractionCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( LayoutBuilder(
children: [ builder: (context, constraints) {
TextButton.icon( final isNarrow = constraints.maxWidth < 520;
onPressed: onShowInfo, final buttons = [
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( TextButton.icon(
onPressed: onOpenLegs, onPressed: onShowInfo,
icon: const Icon(Icons.view_list), icon: const Icon(Icons.info_outline),
label: const Text('Legs'), label: const Text('Details'),
), ),
],
const Spacer(),
if (selectionMode && onToggleSelect != null)
TextButton.icon( TextButton.icon(
onPressed: onToggleSelect, onPressed: onOpenTimeline,
icon: Icon( icon: const Icon(Icons.timeline),
isSelected label: const Text('Timeline'),
? Icons.remove_circle_outline ),
: Icons.add_circle_outline, if (hasMileageOrTrips && onOpenLegs != null)
TextButton.icon(
onPressed: onOpenLegs,
icon: const Icon(Icons.view_list),
label: const Text('Legs'),
), ),
label: Text(isSelected ? 'Remove' : 'Add to entry'), ];
),
], final addButton = selectionMode && onToggleSelect != null
? TextButton.icon(
onPressed: onToggleSelect,
icon: Icon(
isSelected
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
)
: null;
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 4,
children: buttons,
),
if (addButton != null) ...[
const SizedBox(height: 6),
addButton,
],
],
);
}
return Row(
children: [
...buttons.expand((btn) sync* {
yield btn;
yield const SizedBox(width: 8);
}).take(buttons.length * 2 - 1),
const Spacer(),
if (addButton != null) addButton,
],
);
},
), ),
Wrap( Wrap(
spacing: 8, spacing: 8,

View File

@@ -197,7 +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, super.powering = true,
}) : extra = extra ?? const {}, }) : extra = extra ?? const {},
super( super(
id: locoId, id: locoId,
@@ -207,7 +207,6 @@ 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(
@@ -400,7 +399,7 @@ class LocoChange {
}); });
factory LocoChange.fromJson(Map<String, dynamic> json) { factory LocoChange.fromJson(Map<String, dynamic> json) {
String _clean(dynamic value) { String cleanValue(dynamic value) {
final str = value?.toString().trim() ?? ''; final str = value?.toString().trim() ?? '';
if (str.isEmpty || str == '-' || str == '?') return ''; if (str.isEmpty || str == '-' || str == '?') return '';
return str; return str;
@@ -417,15 +416,15 @@ class LocoChange {
final validFromRaw = json['valid_from'] ?? json['validFrom']; final validFromRaw = json['valid_from'] ?? json['validFrom'];
return LocoChange( return LocoChange(
locoId: _asInt(json['loco_id']), locoId: _asInt(json['loco_id']),
locoClass: _clean(json['loco_class']), locoClass: cleanValue(json['loco_class']),
locoNumber: _clean(json['loco_number']), locoNumber: cleanValue(json['loco_number']),
locoName: _clean(json['loco_name']), locoName: cleanValue(json['loco_name']),
attrCode: _asString(json['attr_code']), attrCode: _asString(json['attr_code']),
attrDisplay: _clean(json['attr_display']), attrDisplay: cleanValue(json['attr_display']),
valueDisplay: _clean(valueLabel), valueDisplay: cleanValue(valueLabel),
validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''), validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''),
approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''), approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''),
approvedBy: _clean(json['approved_by']), approvedBy: cleanValue(json['approved_by']),
); );
} }

View File

@@ -5,17 +5,24 @@ typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function(); typedef UnauthorizedHandler = Future<void> Function();
class ApiService { class ApiService {
final String baseUrl; String _baseUrl;
final http.Client _client; final http.Client _client;
final Duration timeout; final Duration timeout;
TokenProvider? _getToken; TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized; UnauthorizedHandler? _onUnauthorized;
ApiService({ ApiService({
required this.baseUrl, required String baseUrl,
http.Client? client, http.Client? client,
this.timeout = const Duration(seconds: 30), this.timeout = const Duration(seconds: 30),
}) : _client = client ?? http.Client(); }) : _baseUrl = baseUrl,
_client = client ?? http.Client();
String get baseUrl => _baseUrl;
void setBaseUrl(String url) {
_baseUrl = url;
}
void setTokenProvider(TokenProvider provider) { void setTokenProvider(TokenProvider provider) {
_getToken = provider; _getToken = provider;

View File

@@ -48,6 +48,9 @@ class DataService extends ChangeNotifier {
List<LocoChange> get latestLocoChanges => _latestLocoChanges; List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false; bool _isLatestLocoChangesLoading = false;
bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading; bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading;
bool _latestLocoChangesHasMore = false;
bool get latestLocoChangesHasMore => _latestLocoChangesHasMore;
int _latestLocoChangesFetched = 0;
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) =>
@@ -72,9 +75,14 @@ class DataService extends ChangeNotifier {
bool get isEventFieldsLoading => _isEventFieldsLoading; bool get isEventFieldsLoading => _isEventFieldsLoading;
// Station Data // Station Data
List<Station>? _cachedStations; final Map<String, List<Station>> _stationCache = {};
DateTime? _stationsFetchedAt; final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};
Future<List<Station>>? _stationsInFlight; List<String> _stationNetworks = [];
Map<String, List<String>> _stationCountryNetworks = {};
DateTime? _stationFiltersFetchedAt;
List<String> get stationNetworks => _stationNetworks;
Map<String, List<String>> get stationCountryNetworks =>
_stationCountryNetworks;
List<String> stations = [""]; List<String> stations = [""];
@@ -365,37 +373,75 @@ class DataService extends ChangeNotifier {
0; 0;
} }
Future<List<Station>> fetchStations() async { Future<void> fetchStationFilters() async {
final now = DateTime.now(); final now = DateTime.now();
if (_stationFiltersFetchedAt != null &&
// If cache exists and is less than 30 minutes old, return it now.difference(_stationFiltersFetchedAt!) < const Duration(minutes: 30) &&
if (_cachedStations != null && _stationNetworks.isNotEmpty) {
_stationsFetchedAt != null && return;
now.difference(_stationsFetchedAt!) < Duration(minutes: 30)) {
return _cachedStations!;
} }
try {
final response = await api.get('/stations/filter');
if (response is List && response.isNotEmpty && response.first is Map) {
final map = Map<String, dynamic>.from(response.first as Map);
final networks = (map['networks'] as List? ?? const [])
.whereType<String>()
.toList();
final countryNetworksRaw =
map['country_networks'] as Map? ?? const <String, dynamic>{};
final countryNetworks = <String, List<String>>{};
countryNetworksRaw.forEach((key, value) {
if (value is List) {
countryNetworks[key] = value.whereType<String>().toList();
}
});
_stationNetworks = networks;
_stationCountryNetworks = countryNetworks;
_stationFiltersFetchedAt = now;
}
} catch (e) {
debugPrint('Failed to fetch station filters: $e');
}
}
if (_stationsInFlight != null) return _stationsInFlight!; String _stationKey(List<String> countries, List<String> networks) {
final c = countries..sort();
final n = networks..sort();
return 'c:${c.join('|')};n:${n.join('|')}';
}
_stationsInFlight = () async { Future<List<Station>> fetchStations({
List<String> countries = const [],
List<String> networks = const [],
}) async {
final key = _stationKey(List.from(countries), List.from(networks));
if (_stationCache.containsKey(key)) return _stationCache[key]!;
final inflight = _stationInFlightByKey[key];
if (inflight != null) return inflight;
final future = () async {
try { try {
final response = await api.get('/location'); final response = await api.post('/location', {
'countries_filter': countries,
'network_filter': networks,
});
if (response is! List) return const <Station>[]; if (response is! List) return const <Station>[];
final parsed = response final parsed = response
.whereType<Map>() .whereType<Map>()
.map((e) => Station.fromJson(Map<String, dynamic>.from(e))) .map((e) => Station.fromJson(Map<String, dynamic>.from(e)))
.toList(); .toList();
_cachedStations = parsed; _stationCache[key] = parsed;
_stationsFetchedAt = now;
return parsed; return parsed;
} catch (e) { } catch (e) {
debugPrint('Failed to fetch stations: $e'); debugPrint('Failed to fetch stations: $e');
return const <Station>[]; return const <Station>[];
} finally { } finally {
_stationsInFlight = null; _stationInFlightByKey.remove(key);
} }
}(); }();
return _stationsInFlight!; _stationInFlightByKey[key] = future;
return future;
} }
} }

View File

@@ -115,7 +115,11 @@ extension DataServiceTraction on DataService {
return _locoClasses; return _locoClasses;
} }
Future<void> fetchLatestLocoChanges({int limit = 25, int offset = 0}) async { Future<void> fetchLatestLocoChanges({
int limit = 100,
int offset = 0,
bool append = false,
}) async {
_isLatestLocoChangesLoading = true; _isLatestLocoChangesLoading = true;
_notifyAsync(); _notifyAsync();
try { try {
@@ -138,16 +142,41 @@ extension DataServiceTraction on DataService {
); );
} }
} }
_latestLocoChanges = parsed; if (append) {
_latestLocoChanges = [..._latestLocoChanges, ...parsed];
} else {
_latestLocoChanges = parsed;
}
final fetchedCount = parsed.length;
_latestLocoChangesFetched = append
? offset + fetchedCount
: fetchedCount;
_latestLocoChangesHasMore = _latestLocoChangesFetched < 5000;
} else { } else {
throw Exception('Unexpected latest loco changes response: $json'); throw Exception('Unexpected latest loco changes response: $json');
} }
} catch (e) { } catch (e) {
debugPrint('Failed to fetch latest loco changes: $e'); debugPrint('Failed to fetch latest loco changes: $e');
_latestLocoChanges = []; _latestLocoChanges = [];
_latestLocoChangesHasMore = false;
_latestLocoChangesFetched = 0;
} finally { } finally {
_isLatestLocoChangesLoading = false; _isLatestLocoChangesLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
try {
final path = Uri.encodeComponent(locoClass);
final json = await api.get('/loco/class/stats/$path/user');
if (json is Map) {
return Map<String, dynamic>.from(json);
}
debugPrint('Unexpected class stats response for $locoClass: $json');
} catch (e) {
debugPrint('Failed to fetch class stats for $locoClass: $e');
}
return null;
}
} }

View File

@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class EndpointService extends ChangeNotifier {
EndpointService() {
_load();
}
static const String defaultBaseUrl = 'https://mileograph.co.uk/api/v1';
static const String _prefsKey = 'api_base_url';
String _baseUrl = defaultBaseUrl;
bool _loaded = false;
String get baseUrl => _baseUrl;
bool get isLoaded => _loaded;
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved != null && saved.trim().isNotEmpty) {
_baseUrl = saved;
}
_loaded = true;
notifyListeners();
}
Future<void> setBaseUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) return;
_baseUrl = trimmed;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, _baseUrl);
notifyListeners();
}
}

View File

@@ -1,4 +1,5 @@
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -11,6 +12,7 @@ import 'package:mileograph_flutter/components/pages/loco_legs.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.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_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/trips.dart'; import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
@@ -83,8 +85,9 @@ class _MyAppState extends State<MyApp> {
redirect: (context, state) { redirect: (context, state) {
final loggedIn = auth.isLoggedIn; final loggedIn = auth.isLoggedIn;
final loggingIn = state.uri.toString() == '/login'; final loggingIn = state.uri.toString() == '/login';
final atSettings = state.uri.toString() == '/settings';
if (!loggedIn && !loggingIn) return '/login'; if (!loggedIn && !loggingIn && !atSettings) return '/login';
if (loggedIn && loggingIn) return '/'; if (loggedIn && loggingIn) return '/';
return null; return null;
}, },
@@ -157,6 +160,10 @@ class _MyAppState extends State<MyApp> {
], ],
), ),
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
], ],
); );
} }
@@ -183,6 +190,14 @@ class _MyAppState extends State<MyApp> {
} }
} }
class _BackIntent extends Intent {
const _BackIntent();
}
class _ForwardIntent extends Intent {
const _ForwardIntent();
}
class MyHomePage extends StatefulWidget { class MyHomePage extends StatefulWidget {
final Widget child; final Widget child;
const MyHomePage({super.key, required this.child}); const MyHomePage({super.key, required this.child});
@@ -200,13 +215,14 @@ class _MyHomePageState extends State<MyHomePage> {
} }
await NavigationGuard.attemptNavigation(() async { await NavigationGuard.attemptNavigation(() async {
if (!mounted) return; if (!mounted) return;
context.go(contentPages[index]); _navigateToIndex(index);
}); });
} }
int? _lastTabIndex; final List<int> _history = [];
final List<int> _tabHistory = []; int _historyPosition = -1;
bool _handlingBackNavigation = false; final List<int> _forwardHistory = [];
bool _suppressRecord = false;
bool _fetched = false; bool _fetched = false;
@@ -250,7 +266,7 @@ class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri; final uri = GoRouterState.of(context).uri;
final pageIndex = tabIndexForPath(uri.path); final pageIndex = tabIndexForPath(uri.path);
_recordTabChange(pageIndex); _syncHistory(pageIndex);
if (pageIndex != _addTabIndex) { if (pageIndex != _addTabIndex) {
NavigationGuard.unregister(); NavigationGuard.unregister();
} }
@@ -263,131 +279,227 @@ class _MyHomePageState extends State<MyHomePage> {
? widget.child ? widget.child
: const Center(child: CircularProgressIndicator()); : const Center(child: CircularProgressIndicator());
return PopScope( final scaffold = LayoutBuilder(
canPop: false, builder: (context, constraints) {
onPopInvokedWithResult: (didPop, _) async { final isWide = constraints.maxWidth >= 900;
if (didPop) return; 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();
final shellNav = _shellNavigatorKey.currentState; return Scaffold(
if (shellNav != null && shellNav.canPop()) { appBar: AppBar(
shellNav.pop(); backgroundColor: Theme.of(context).colorScheme.inversePrimary,
return; title: Text.rich(
} TextSpan(
children: const [
if (_tabHistory.isNotEmpty) { TextSpan(text: "Mile"),
final previousTab = _tabHistory.removeLast(); TextSpan(text: "O", style: TextStyle(color: Colors.red)),
if (!mounted) return; TextSpan(text: "graph"),
_handlingBackNavigation = true; ],
context.go(contentPages[previousTab]); style: const TextStyle(
return; decoration: TextDecoration.none,
} color: Colors.white,
fontFamily: "Tomatoes",
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 actions: [
? null const IconButton(
: NavigationBar( onPressed: null,
selectedIndex: pageIndex, icon: Icon(Icons.account_circle),
onDestinationSelected: (int index) => ),
_onItemTapped(index, pageIndex), IconButton(
destinations: navBarDestinations, tooltip: 'Settings',
), onPressed: () => context.go('/settings'),
body: isWide icon: const Icon(Icons.settings),
? Row( ),
children: [ IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
SafeArea( ],
child: NavigationRail( ),
selectedIndex: pageIndex, bottomNavigationBar: isWide
extended: railExtended, ? null
labelType: railExtended : NavigationBar(
? NavigationRailLabelType.none selectedIndex: pageIndex,
: NavigationRailLabelType.selected, onDestinationSelected: (int index) =>
onDestinationSelected: (int index) => _onItemTapped(index, pageIndex),
_onItemTapped(index, pageIndex), destinations: navBarDestinations,
destinations: navRailDestinations, ),
), 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), const VerticalDivider(width: 1),
], Expanded(child: currentPage),
) ],
: currentPage, )
); : currentPage,
);
},
);
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(),
},
child: Actions(
actions: {
_BackIntent: CallbackAction<_BackIntent>(
onInvoke: (_) {
_handleBackNavigation(allowExit: false, recordForward: true);
return null;
},
),
_ForwardIntent: CallbackAction<_ForwardIntent>(
onInvoke: (_) {
_handleForwardNavigation();
return null;
},
),
}, },
child: Focus(
autofocus: true,
child: Listener(
onPointerDown: _handlePointerButtons,
behavior: HitTestBehavior.opaque,
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
await _handleBackNavigation(allowExit: true, recordForward: false);
},
child: scaffold,
),
),
),
), ),
); );
} }
void _recordTabChange(int pageIndex) { void _handlePointerButtons(PointerDownEvent event) {
final last = _lastTabIndex; // Support mouse back/forward buttons.
if (last == null) { if (event.buttons == kBackMouseButton) {
_lastTabIndex = pageIndex; _handleBackNavigation(allowExit: false, recordForward: true);
return; } else if (event.buttons == kForwardMouseButton) {
_handleForwardNavigation();
} }
if (last == pageIndex) return; }
if (_handlingBackNavigation) { int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path);
_handlingBackNavigation = false;
_lastTabIndex = pageIndex; Future<bool> _handleBackNavigation({
return; bool allowExit = false,
bool recordForward = false,
}) async {
final pageIndex = _currentPageIndex;
final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) {
shellNav.pop();
return true;
} }
if (_tabHistory.isEmpty || _tabHistory.last != last) { if (_historyPosition > 0) {
_tabHistory.add(last); if (recordForward) _pushForward(pageIndex);
_historyPosition -= 1;
_suppressRecord = true;
context.go(contentPages[_history[_historyPosition]]);
return true;
} }
_lastTabIndex = pageIndex;
if (pageIndex != 0) {
if (recordForward) _pushForward(pageIndex);
_suppressRecord = true;
context.go(contentPages[0]);
return true;
}
if (allowExit) {
SystemNavigator.pop();
return true;
}
return false;
}
Future<bool> _handleForwardNavigation() async {
if (_forwardHistory.isEmpty) return false;
final nextTab = _forwardHistory.removeLast();
// Move cursor forward, keeping history in sync.
if (_historyPosition < _history.length - 1) {
_historyPosition += 1;
_history[_historyPosition] = nextTab;
if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length);
}
} else {
_history.add(nextTab);
_historyPosition = _history.length - 1;
}
_suppressRecord = true;
if (!mounted) return false;
context.go(contentPages[nextTab]);
return true;
}
void _pushForward(int pageIndex) {
if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) {
_forwardHistory.add(pageIndex);
}
}
void _syncHistory(int pageIndex) {
if (_history.isEmpty) {
_history.add(pageIndex);
_historyPosition = 0;
return;
}
if (_suppressRecord) {
_suppressRecord = false;
return;
}
if (_historyPosition >= 0 &&
_historyPosition < _history.length &&
_history[_historyPosition] == pageIndex) {
return;
}
if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length);
}
_history.add(pageIndex);
_historyPosition = _history.length - 1;
_forwardHistory.clear();
}
void _navigateToIndex(int index) {
_suppressRecord = false;
_forwardHistory.clear();
context.go(contentPages[index]);
} }
} }

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.3.0+1 version: 0.3.4+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1