Compare commits
5 Commits
v0.3.1-dev
...
v0.3.4-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d79e7c28 | |||
| 29959f7580 | |||
| d5d204dd19 | |||
| 950978b021 | |||
| dc5ed2567f |
13
lib/app.dart
13
lib/app.dart
@@ -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>()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,17 +36,18 @@ 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: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.bolt, size: 20),
|
const Icon(Icons.bolt, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -73,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}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
HomepageStats? stats,
|
HomepageStats? stats,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isCompact = MediaQuery.of(context).size.width < 720;
|
|
||||||
final greetingName =
|
final greetingName =
|
||||||
stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
|
stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
|
||||||
final totalMileage = stats?.totalMileage ?? 0;
|
final totalMileage = stats?.totalMileage ?? 0;
|
||||||
@@ -111,24 +110,7 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
isCompact
|
_heroHeading(context, greetingName, colorScheme),
|
||||||
? Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_heroHeading(context, greetingName, colorScheme),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_heroActions(context, colorScheme, wrap: true),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _heroHeading(context, greetingName, colorScheme),
|
|
||||||
),
|
|
||||||
_heroActions(context, colorScheme, wrap: false),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
@@ -250,6 +232,8 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
_buildOnThisDayCard(context, data),
|
_buildOnThisDayCard(context, data),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildTripsCard(context, data),
|
_buildTripsCard(context, data),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const LatestLocoChangesPanel(expanded: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -262,8 +246,6 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
TopTractionPanel(),
|
TopTractionPanel(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
LeaderboardPanel(),
|
LeaderboardPanel(),
|
||||||
SizedBox(height: 16),
|
|
||||||
LatestLocoChangesPanel(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -314,52 +296,6 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _heroActions(
|
|
||||||
BuildContext context,
|
|
||||||
ColorScheme colorScheme, {
|
|
||||||
required bool wrap,
|
|
||||||
}) {
|
|
||||||
final buttons = [
|
|
||||||
FilledButton.icon(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: colorScheme.primary,
|
|
||||||
),
|
|
||||||
onPressed: () => context.go('/add'),
|
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
|
||||||
label: const Text('Add entry'),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: () => context.go('/traction'),
|
|
||||||
icon: const Icon(Icons.train),
|
|
||||||
label: const Text('Traction'),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: () => context.go('/trips'),
|
|
||||||
icon: const Icon(Icons.book),
|
|
||||||
label: const Text('Trips'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (wrap) {
|
|
||||||
return Wrap(spacing: 8, runSpacing: 8, children: buttons);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
...buttons
|
|
||||||
.map(
|
|
||||||
(btn) => Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
|
||||||
child: btn,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildOnThisDayCard(BuildContext context, DataService data) {
|
Widget _buildOnThisDayCard(BuildContext context, DataService data) {
|
||||||
final filtered = data.onThisDay
|
final filtered = data.onThisDay
|
||||||
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
213
lib/components/pages/settings.dart
Normal file
213
lib/components/pages/settings.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -288,6 +288,11 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
|
|
||||||
Future<void> handleDelete() async {
|
Future<void> handleDelete() async {
|
||||||
if (deleting || trip.legs.isNotEmpty) return;
|
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>(
|
final ok = await showDialog<bool>(
|
||||||
context: sheetCtx,
|
context: sheetCtx,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
@@ -309,11 +314,8 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (ok != true) return;
|
if (ok != true || !mounted) return;
|
||||||
|
|
||||||
final data = context.read<DataService>();
|
|
||||||
final api = data.api;
|
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
||||||
setSheetState(() => deleting = true);
|
setSheetState(() => deleting = true);
|
||||||
try {
|
try {
|
||||||
await api.delete('/trips/delete/${trip.id}');
|
await api.delete('/trips/delete/${trip.id}');
|
||||||
@@ -321,18 +323,16 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
data.fetchTripDetails(),
|
data.fetchTripDetails(),
|
||||||
data.fetchTrips(),
|
data.fetchTrips(),
|
||||||
]);
|
]);
|
||||||
if (context.mounted) {
|
if (!mounted) return;
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Deleted "${trip.name}"')),
|
SnackBar(content: Text('Deleted "${trip.name}"')),
|
||||||
);
|
);
|
||||||
Navigator.of(sheetCtx).pop();
|
navigator.pop();
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (!mounted) return;
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Failed to delete trip: $e')),
|
SnackBar(content: Text('Failed to delete trip: $e')),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setSheetState(() => deleting = false);
|
if (mounted) setSheetState(() => deleting = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
lib/services/endpoint_service.dart
Normal file
36
lib/services/endpoint_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.1+1
|
version: 0.3.4+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user