Compare commits
11 Commits
v0.3.3-dev
...
v0.5.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7139cfcc99 | |||
| 1c15546b66 | |||
| e1ad1ea685 | |||
| 9b307ab56b | |||
| 8cf43c76e2 | |||
| 2600e90efa | |||
| a9bc6c306c | |||
| 54026aa93a | |||
| 0971124fd4 | |||
| 4bd6f0bbed | |||
| 44d79e7c28 |
@@ -10,7 +10,7 @@ env:
|
|||||||
JAVA_VERSION: "17"
|
JAVA_VERSION: "17"
|
||||||
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
|
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
|
||||||
FLUTTER_VERSION: "3.38.5"
|
FLUTTER_VERSION: "3.38.5"
|
||||||
BUILD_WINDOWS: "false" # set to "true" when you actually want Windows builds
|
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
|
||||||
GITEA_BASE_URL: https://git.tgj.services
|
GITEA_BASE_URL: https://git.tgj.services
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -20,6 +20,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
base_version: ${{ steps.meta.outputs.base }}
|
base_version: ${{ steps.meta.outputs.base }}
|
||||||
release_tag: ${{ steps.meta.outputs.release_tag }}
|
release_tag: ${{ steps.meta.outputs.release_tag }}
|
||||||
|
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,12 +30,24 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
||||||
BASE_VERSION=${RAW_VERSION%%+*}
|
BASE_VERSION=${RAW_VERSION%%+*}
|
||||||
TAG="v${BASE_VERSION}"
|
VERSION="${BASE_VERSION}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
DEV_SUFFIX=""
|
||||||
|
|
||||||
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
||||||
TAG="v${BASE_VERSION}-dev"
|
DEV_ITER="${GITHUB_RUN_NUMBER:-}"
|
||||||
|
if [ -z "$DEV_ITER" ]; then
|
||||||
|
DEV_ITER=$(git rev-list --count HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEV_SUFFIX="-dev.${DEV_ITER}"
|
||||||
|
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Fail if release already exists
|
- name: Fail if release already exists
|
||||||
env:
|
env:
|
||||||
@@ -268,11 +281,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
BASE="${{ needs.meta.outputs.base_version }}"
|
BASE="${{ needs.meta.outputs.base_version }}"
|
||||||
TAG="${{ needs.meta.outputs.release_tag }}"
|
TAG="${{ needs.meta.outputs.release_tag }}"
|
||||||
|
DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}"
|
||||||
|
if [ -z "$DEV_SUFFIX" ]; then
|
||||||
|
echo "dev_suffix is empty; expected '-dev.<n>'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
|
VERSION="${BASE}${DEV_SUFFIX}"
|
||||||
|
APK_NAME="mileograph-${VERSION}.apk"
|
||||||
|
|
||||||
|
mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}"
|
||||||
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "apk=artifacts/mileograph-${BASE}-dev.apk" >> "$GITHUB_OUTPUT"
|
echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create prerelease on Gitea
|
- name: Create prerelease on Gitea
|
||||||
if: ${{ github.ref == 'refs/heads/dev' }}
|
if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ class App extends StatelessWidget {
|
|||||||
ChangeNotifierProvider<AuthService>(
|
ChangeNotifierProvider<AuthService>(
|
||||||
create: (context) => AuthService(api: context.read<ApiService>()),
|
create: (context) => AuthService(api: context.read<ApiService>()),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<DataService>(
|
ChangeNotifierProxyProvider<AuthService, DataService>(
|
||||||
create: (context) => DataService(api: context.read<ApiService>()),
|
create: (context) => DataService(api: context.read<ApiService>()),
|
||||||
|
update: (context, auth, data) {
|
||||||
|
data ??= DataService(api: context.read<ApiService>());
|
||||||
|
data.handleAuthChanged(auth.userId);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -35,16 +42,16 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
|||||||
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(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Latest loco changes',
|
'Latest Loco Changes',
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
@@ -73,52 +80,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: (context, index) =>
|
child: OutlinedButton.icon(
|
||||||
const Divider(height: 1),
|
onPressed: isLoading ? null : _loadMore,
|
||||||
itemBuilder: (context, index) {
|
icon: isLoading
|
||||||
final change = changes[index];
|
? const SizedBox(
|
||||||
return ListTile(
|
height: 14,
|
||||||
dense: true,
|
width: 14,
|
||||||
contentPadding: EdgeInsets.zero,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
title: Text(
|
)
|
||||||
change.locoLabel,
|
: const Icon(Icons.expand_more),
|
||||||
style: textTheme.titleSmall?.copyWith(
|
label: Text(isLoading ? 'Loading...' : 'Show more'),
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('${change.changeLabel}: ${change.valueLabel}'),
|
|
||||||
Text(
|
|
||||||
change.approvedDateLabel,
|
|
||||||
style: textTheme.labelSmall?.copyWith(
|
|
||||||
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: change.approvedBy.isEmpty
|
|
||||||
? null
|
|
||||||
: Text(
|
|
||||||
change.approvedBy,
|
|
||||||
style: textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: (_, index) => 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}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,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 +16,161 @@ 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 beginTimeWidget = _timeWithDelay(
|
||||||
if (showDate) Text(_formatDateTime(leg.beginTime)),
|
context,
|
||||||
if (leg.headcode.isNotEmpty)
|
leg.beginTime,
|
||||||
Text(
|
leg.beginDelayMinutes,
|
||||||
'Headcode: ${leg.headcode}',
|
includeDate: widget.showDate,
|
||||||
style: textTheme.labelSmall,
|
);
|
||||||
),
|
final endTimeWidget = leg.endTime == null
|
||||||
if (leg.network.isNotEmpty)
|
? null
|
||||||
Text(
|
: _timeWithDelay(
|
||||||
leg.network,
|
context,
|
||||||
style: textTheme.labelSmall,
|
leg.endTime!,
|
||||||
),
|
leg.endDelayMinutes,
|
||||||
],
|
includeDate: widget.showDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
final routeText = Text(
|
||||||
|
'${leg.start} → ${leg.end}',
|
||||||
|
softWrap: true,
|
||||||
|
);
|
||||||
|
if (!isWide) {
|
||||||
|
final timeStyle = Theme.of(context).textTheme.labelSmall;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
routeText,
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_timeWithDelay(
|
||||||
|
context,
|
||||||
|
leg.beginTime,
|
||||||
|
leg.beginDelayMinutes,
|
||||||
|
includeDate: widget.showDate,
|
||||||
|
style: timeStyle,
|
||||||
|
),
|
||||||
|
if (endTimeWidget != null) ...[
|
||||||
|
const Text('·'),
|
||||||
|
_timeWithDelay(
|
||||||
|
context,
|
||||||
|
leg.endTime!,
|
||||||
|
leg.endDelayMinutes,
|
||||||
|
includeDate: widget.showDate,
|
||||||
|
style: timeStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
beginTimeWidget,
|
||||||
|
const Text('·'),
|
||||||
|
routeText,
|
||||||
|
if (endTimeWidget != null) ...[
|
||||||
|
const Text('·'),
|
||||||
|
endTimeWidget,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
subtitle: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isWide = constraints.maxWidth > 520;
|
||||||
|
final timeWidget = _timeWithDelay(
|
||||||
|
context,
|
||||||
|
leg.beginTime,
|
||||||
|
leg.beginDelayMinutes,
|
||||||
|
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 {
|
||||||
|
if (tractionWrap != null) {
|
||||||
|
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 +197,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 +207,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),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -101,6 +244,12 @@ class LegCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
|
if (_hasTrainDetails(leg)) ...[
|
||||||
|
Text('Train', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
..._buildTrainDetails(leg, textTheme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
if (routeSegments.isNotEmpty) ...[
|
if (routeSegments.isNotEmpty) ...[
|
||||||
Text('Route', style: textTheme.titleSmall),
|
Text('Route', style: textTheme.titleSmall),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -114,15 +263,87 @@ 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;
|
||||||
|
|
||||||
|
if (!context.mounted) 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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _timeWithDelay(
|
||||||
|
BuildContext context,
|
||||||
|
DateTime time,
|
||||||
|
int? delay, {
|
||||||
|
bool includeDate = true,
|
||||||
|
TextStyle? style,
|
||||||
|
}) {
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final delayMinutes = delay ?? 0;
|
||||||
|
final delayText =
|
||||||
|
delayMinutes == 0 ? null : '${delayMinutes > 0 ? '+' : ''}$delayMinutes';
|
||||||
|
final delayColor = delayMinutes == 0
|
||||||
|
? null
|
||||||
|
: (delayMinutes < 0 ? Colors.green : colorScheme.error);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatDateTime(time, includeDate: includeDate),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
if (delayText != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'$delayText m',
|
||||||
|
style:
|
||||||
|
(style ?? textTheme.labelSmall)?.copyWith(color: delayColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +379,51 @@ class LegCard extends StatelessWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _hasTrainDetails(Leg leg) {
|
||||||
|
return leg.headcode.isNotEmpty ||
|
||||||
|
leg.origin.isNotEmpty ||
|
||||||
|
leg.destination.isNotEmpty ||
|
||||||
|
leg.originTime != null ||
|
||||||
|
leg.destinationTime != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildTrainDetails(Leg leg, TextTheme textTheme) {
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
if (leg.headcode.isNotEmpty) {
|
||||||
|
widgets.add(
|
||||||
|
Text(
|
||||||
|
'Headcode: ${leg.headcode}',
|
||||||
|
style: textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final originLine = _locationLine(
|
||||||
|
'Origin',
|
||||||
|
leg.origin,
|
||||||
|
leg.originTime,
|
||||||
|
);
|
||||||
|
if (originLine != null) {
|
||||||
|
widgets.add(Text(originLine, style: textTheme.bodyMedium));
|
||||||
|
}
|
||||||
|
final destinationLine = _locationLine(
|
||||||
|
'Destination',
|
||||||
|
leg.destination,
|
||||||
|
leg.destinationTime,
|
||||||
|
);
|
||||||
|
if (destinationLine != null) {
|
||||||
|
widgets.add(Text(destinationLine, style: textTheme.bodyMedium));
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _locationLine(String label, String location, DateTime? time) {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (location.trim().isNotEmpty) parts.add(location.trim());
|
||||||
|
if (time != null) parts.add(_formatDateTime(time));
|
||||||
|
if (parts.isEmpty) return null;
|
||||||
|
return '$label: ${parts.join(' · ')}';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildRouteList(List<String> segments) {
|
Widget _buildRouteList(List<String> segments) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -178,38 +444,7 @@ class LegCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _parseRouteSegments(String route) {
|
List<String> _parseRouteSegments(List<String> route) {
|
||||||
final trimmed = route.trim();
|
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
|
||||||
if (trimmed.isEmpty) return [];
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(trimmed);
|
|
||||||
if (decoded is List) {
|
|
||||||
return decoded.map((e) => e.toString()).toList();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
||||||
try {
|
|
||||||
final replaced = trimmed.replaceAll("'", '"');
|
|
||||||
final decoded = jsonDecode(replaced);
|
|
||||||
if (decoded is List) {
|
|
||||||
return decoded.map((e) => e.toString()).toList();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
if (trimmed.contains('->')) {
|
|
||||||
return trimmed
|
|
||||||
.split('->')
|
|
||||||
.map((e) => e.trim())
|
|
||||||
.where((e) => e.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
if (trimmed.contains(',')) {
|
|
||||||
return trimmed
|
|
||||||
.split(',')
|
|
||||||
.map((e) => e.trim())
|
|
||||||
.where((e) => e.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
return [trimmed];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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/services/authservice.dart';
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -16,7 +17,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkExistingSession());
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => _checkExistingSession(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkExistingSession() async {
|
Future<void> _checkExistingSession() async {
|
||||||
@@ -26,7 +29,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
await auth.tryRestoreSession();
|
await auth.tryRestoreSession();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.go('/');
|
context.go('/dashboard');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _checkingSession = false);
|
if (mounted) setState(() => _checkingSession = false);
|
||||||
}
|
}
|
||||||
@@ -70,23 +73,39 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_checkingSession)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 12),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 24,
|
|
||||||
width: 24,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 50),
|
const SizedBox(height: 50),
|
||||||
const LoginPanel(),
|
const LoginPanel(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings, color: Colors.grey),
|
icon: const Icon(Icons.settings, color: Colors.grey),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
onPressed: () => context.go('/settings'),
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) => const SettingsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
if (_checkingSession) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Trying to log in',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -179,14 +198,15 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_loggingIn = false;
|
_loggingIn = false;
|
||||||
});
|
});
|
||||||
|
context.go('/dashboard');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_loggingIn = false;
|
_loggingIn = false;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Login failed: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Login failed: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,14 +317,16 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Registration successful. Please log in.')),
|
const SnackBar(
|
||||||
|
content: Text('Registration successful. Please log in.'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
widget.onBack();
|
widget.onBack();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Registration failed: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _registering = false);
|
if (mounted) setState(() => _registering = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -244,8 +246,6 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
TopTractionPanel(),
|
TopTractionPanel(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
LeaderboardPanel(),
|
LeaderboardPanel(),
|
||||||
SizedBox(height: 16),
|
|
||||||
LatestLocoChangesPanel(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -509,7 +509,7 @@ class _DashboardState extends State<Dashboard> {
|
|||||||
icon: Icons.bookmark,
|
icon: Icons.bookmark,
|
||||||
title: 'Trips',
|
title: 'Trips',
|
||||||
action: TextButton(
|
action: TextButton(
|
||||||
onPressed: () => context.push('/trips'),
|
onPressed: () => context.push('/logbook/trips'),
|
||||||
child: const Text('View all'),
|
child: const Text('View all'),
|
||||||
),
|
),
|
||||||
child: trips.isEmpty
|
child: trips.isEmpty
|
||||||
|
|||||||
@@ -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,118 @@ class _FieldInput extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final name = field.name.toLowerCase();
|
||||||
|
if (name == 'max_speed') {
|
||||||
|
final unit = entry.unit ?? 'kph';
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: value?.toString(),
|
||||||
|
onChanged: (val) {
|
||||||
|
final parsed = double.tryParse(val);
|
||||||
|
onChanged(parsed, 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 +441,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
42
lib/components/pages/logbook.dart
Normal file
42
lib/components/pages/logbook.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/legs.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/trips.dart';
|
||||||
|
|
||||||
|
enum LogbookTab { entries, trips }
|
||||||
|
|
||||||
|
class LogbookPage extends StatelessWidget {
|
||||||
|
const LogbookPage({super.key, this.initialTab = LogbookTab.entries});
|
||||||
|
|
||||||
|
final LogbookTab initialTab;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initialIndex = initialTab == LogbookTab.trips ? 1 : 0;
|
||||||
|
return DefaultTabController(
|
||||||
|
key: ValueKey(initialTab),
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
length: 2,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TabBar(
|
||||||
|
onTap: (index) {
|
||||||
|
final dest = index == 0 ? '/logbook/entries' : '/logbook/trips';
|
||||||
|
final current = GoRouterState.of(context).uri.path;
|
||||||
|
if (current != dest) {
|
||||||
|
context.go(dest);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Entries'),
|
||||||
|
Tab(text: 'Trips'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(children: const [LegsPage(), TripsPage()]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/components/pages/more.dart
Normal file
81
lib/components/pages/more.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/profile.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
|
|
||||||
|
class MorePage extends StatelessWidget {
|
||||||
|
const MorePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Navigator(
|
||||||
|
onGenerateRoute: (settings) {
|
||||||
|
final name = settings.name ?? '/';
|
||||||
|
Widget page;
|
||||||
|
switch (name) {
|
||||||
|
case '/settings':
|
||||||
|
page = const SettingsPage();
|
||||||
|
break;
|
||||||
|
case '/profile':
|
||||||
|
page = const ProfilePage();
|
||||||
|
break;
|
||||||
|
case '/stats':
|
||||||
|
page = const StatsPage();
|
||||||
|
break;
|
||||||
|
case '/more/settings':
|
||||||
|
page = const SettingsPage();
|
||||||
|
break;
|
||||||
|
case '/more/profile':
|
||||||
|
page = const ProfilePage();
|
||||||
|
break;
|
||||||
|
case '/more/stats':
|
||||||
|
page = const StatsPage();
|
||||||
|
break;
|
||||||
|
case '/':
|
||||||
|
default:
|
||||||
|
page = _MoreHome();
|
||||||
|
}
|
||||||
|
return MaterialPageRoute(builder: (_) => page, settings: settings);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MoreHome extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'More',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.emoji_events),
|
||||||
|
title: const Text('Badges'),
|
||||||
|
onTap: () => Navigator.of(context).pushNamed('/more/profile'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.bar_chart),
|
||||||
|
title: const Text('Stats'),
|
||||||
|
onTap: () => Navigator.of(context).pushNamed('/more/stats'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Settings'),
|
||||||
|
onTap: () => Navigator.of(context).pushNamed('/more/settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
if (choice == _ExitChoice.save) {
|
if (choice == _ExitChoice.save) {
|
||||||
await _saveDraftEntry(draftId: _activeDraftId);
|
await _saveDraftEntry(draftId: _activeDraftId);
|
||||||
} else if (choice == _ExitChoice.discard) {
|
} else if (choice == _ExitChoice.discard) {
|
||||||
|
// Delay reset to avoid setState during the dialog/build phase.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
if (!mounted) return false;
|
||||||
await _resetFormState(clearDraft: true);
|
await _resetFormState(clearDraft: true);
|
||||||
_activeDraftId = null;
|
_activeDraftId = null;
|
||||||
}
|
}
|
||||||
@@ -29,12 +32,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _formIsEmpty() {
|
bool _formIsEmpty() {
|
||||||
|
final beginDelayVal = _parseDelayMinutes(_beginDelayController.text);
|
||||||
|
final endDelayVal = _parseDelayMinutes(_endDelayController.text);
|
||||||
return _startController.text.trim().isEmpty &&
|
return _startController.text.trim().isEmpty &&
|
||||||
_endController.text.trim().isEmpty &&
|
_endController.text.trim().isEmpty &&
|
||||||
_headcodeController.text.trim().isEmpty &&
|
_headcodeController.text.trim().isEmpty &&
|
||||||
_notesController.text.trim().isEmpty &&
|
_notesController.text.trim().isEmpty &&
|
||||||
_networkController.text.trim().isEmpty &&
|
_networkController.text.trim().isEmpty &&
|
||||||
_mileageController.text.trim().isEmpty &&
|
_mileageController.text.trim().isEmpty &&
|
||||||
|
_originController.text.trim().isEmpty &&
|
||||||
|
_destinationController.text.trim().isEmpty &&
|
||||||
|
beginDelayVal == 0 &&
|
||||||
|
endDelayVal == 0 &&
|
||||||
|
!_hasOriginTime &&
|
||||||
|
!_hasDestinationTime &&
|
||||||
|
!_hasEndTime &&
|
||||||
_routeResult == null &&
|
_routeResult == null &&
|
||||||
_tractionItems.length <= 1;
|
_tractionItems.length <= 1;
|
||||||
}
|
}
|
||||||
@@ -122,6 +134,30 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
"notes": _notesController.text,
|
"notes": _notesController.text,
|
||||||
"mileage": _mileageController.text,
|
"mileage": _mileageController.text,
|
||||||
"network": _networkController.text,
|
"network": _networkController.text,
|
||||||
|
"origin": _originController.text,
|
||||||
|
"destination": _destinationController.text,
|
||||||
|
"hasEndTime": _hasEndTime,
|
||||||
|
"hasOriginTime": _hasOriginTime,
|
||||||
|
"hasDestinationTime": _hasDestinationTime,
|
||||||
|
"endDate": _selectedEndDate.toIso8601String(),
|
||||||
|
"endTime": {
|
||||||
|
"hour": _selectedEndTime.hour,
|
||||||
|
"minute": _selectedEndTime.minute,
|
||||||
|
},
|
||||||
|
"originDate": _selectedOriginDate.toIso8601String(),
|
||||||
|
"originTime": {
|
||||||
|
"hour": _selectedOriginTime.hour,
|
||||||
|
"minute": _selectedOriginTime.minute,
|
||||||
|
},
|
||||||
|
"destinationDate": _selectedDestinationDate.toIso8601String(),
|
||||||
|
"destinationTime": {
|
||||||
|
"hour": _selectedDestinationTime.hour,
|
||||||
|
"minute": _selectedDestinationTime.minute,
|
||||||
|
},
|
||||||
|
"matchOriginToEntry": _matchOriginToEntry,
|
||||||
|
"matchDestinationToEntry": _matchDestinationToEntry,
|
||||||
|
"beginDelay": _parseDelayMinutes(_beginDelayController.text),
|
||||||
|
"endDelay": _parseDelayMinutes(_endDelayController.text),
|
||||||
"useManualMileage": _useManualMileage,
|
"useManualMileage": _useManualMileage,
|
||||||
"selectedTripId": _selectedTripId,
|
"selectedTripId": _selectedTripId,
|
||||||
"routeResult": _routeResult == null
|
"routeResult": _routeResult == null
|
||||||
@@ -200,6 +236,12 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
bool includeTimestamp = true,
|
bool includeTimestamp = true,
|
||||||
}) {
|
}) {
|
||||||
final routeStations = _routeResult?.calculatedRoute ?? [];
|
final routeStations = _routeResult?.calculatedRoute ?? [];
|
||||||
|
final endTime = _legEndDateTime;
|
||||||
|
final originTime = _originDateTime;
|
||||||
|
final destinationTime = _destinationDateTime;
|
||||||
|
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
|
||||||
|
final endDelay =
|
||||||
|
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
|
||||||
final startVal = _useManualMileage
|
final startVal = _useManualMileage
|
||||||
? _startController.text.trim()
|
? _startController.text.trim()
|
||||||
: (routeStations.isNotEmpty ? routeStations.first : '');
|
: (routeStations.isNotEmpty ? routeStations.first : '');
|
||||||
@@ -210,27 +252,33 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
? double.tryParse(_mileageController.text.trim()) ?? 0
|
? double.tryParse(_mileageController.text.trim()) ?? 0
|
||||||
: (_routeResult?.distance ?? 0);
|
: (_routeResult?.distance ?? 0);
|
||||||
final tractionPayload = _buildTractionPayload();
|
final tractionPayload = _buildTractionPayload();
|
||||||
|
final commonPayload = {
|
||||||
|
"leg_trip": _selectedTripId,
|
||||||
|
"leg_begin_time": _legDateTime.toIso8601String(),
|
||||||
|
if (endTime != null) "leg_end_time": endTime.toIso8601String(),
|
||||||
|
if (originTime != null) "leg_origin_time": originTime.toIso8601String(),
|
||||||
|
if (destinationTime != null)
|
||||||
|
"leg_destination_time": destinationTime.toIso8601String(),
|
||||||
|
"leg_notes": _notesController.text.trim(),
|
||||||
|
"leg_headcode": _headcodeController.text.trim(),
|
||||||
|
"leg_network": _networkController.text.trim(),
|
||||||
|
"leg_origin": _originController.text.trim(),
|
||||||
|
"leg_destination": _destinationController.text.trim(),
|
||||||
|
"leg_begin_delay": beginDelay,
|
||||||
|
if (_hasEndTime) "leg_end_delay": endDelay,
|
||||||
|
"locos": tractionPayload,
|
||||||
|
};
|
||||||
final payload = _useManualMileage
|
final payload = _useManualMileage
|
||||||
? {
|
? {
|
||||||
"leg_trip": _selectedTripId,
|
...commonPayload,
|
||||||
"leg_start": startVal,
|
"leg_start": startVal,
|
||||||
"leg_end": endVal,
|
"leg_end": endVal,
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"leg_distance": mileageVal,
|
"leg_distance": mileageVal,
|
||||||
"isKilometers": false,
|
"isKilometers": false,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
"leg_trip": _selectedTripId,
|
...commonPayload,
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_route": routeStations,
|
"leg_route": routeStations,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
"leg_mileage": _routeResult?.distance ?? mileageVal,
|
"leg_mileage": _routeResult?.distance ?? mileageVal,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -265,6 +313,29 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
final beginTime = beginStr == null
|
final beginTime = beginStr == null
|
||||||
? DateTime.now()
|
? DateTime.now()
|
||||||
: DateTime.tryParse(beginStr) ?? DateTime.now();
|
: DateTime.tryParse(beginStr) ?? DateTime.now();
|
||||||
|
final originTimeStr = payload['leg_origin_time'] as String?;
|
||||||
|
final destinationTimeStr = payload['leg_destination_time'] as String?;
|
||||||
|
final originTime =
|
||||||
|
originTimeStr == null ? null : DateTime.tryParse(originTimeStr);
|
||||||
|
final destinationTime = destinationTimeStr == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(destinationTimeStr);
|
||||||
|
final endStr = payload['leg_end_time'] as String?;
|
||||||
|
final endTime =
|
||||||
|
endStr == null ? null : DateTime.tryParse(endStr);
|
||||||
|
final beginDelay =
|
||||||
|
_parseDelayMinutes('${payload['leg_begin_delay'] ?? ''}');
|
||||||
|
final endDelay =
|
||||||
|
_parseDelayMinutes('${payload['leg_end_delay'] ?? ''}');
|
||||||
|
final hasEndTime = endTime != null || endDelay != 0;
|
||||||
|
final matchOrigin = data['matchOriginToEntry'] == true;
|
||||||
|
final matchDestination = data['matchDestinationToEntry'] == true;
|
||||||
|
final hasOriginTime =
|
||||||
|
originTime != null || data['hasOriginTime'] == true;
|
||||||
|
final hasDestinationTime =
|
||||||
|
destinationTime != null || data['hasDestinationTime'] == true;
|
||||||
|
final origin = payload['leg_origin'] as String? ?? '';
|
||||||
|
final destination = payload['leg_destination'] as String? ?? '';
|
||||||
final tripRaw = payload['leg_trip'];
|
final tripRaw = payload['leg_trip'];
|
||||||
final tripId = tripRaw is num ? tripRaw.toInt() : null;
|
final tripId = tripRaw is num ? tripRaw.toInt() : null;
|
||||||
|
|
||||||
@@ -312,6 +383,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
_useManualMileage = useManual;
|
_useManualMileage = useManual;
|
||||||
_selectedDate = beginTime;
|
_selectedDate = beginTime;
|
||||||
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
||||||
|
_selectedEndDate = endTime ?? beginTime;
|
||||||
|
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
|
||||||
|
_hasEndTime = hasEndTime;
|
||||||
|
_matchOriginToEntry = matchOrigin;
|
||||||
|
_matchDestinationToEntry = matchDestination;
|
||||||
|
_selectedOriginDate = originTime ?? beginTime;
|
||||||
|
_selectedOriginTime =
|
||||||
|
TimeOfDay.fromDateTime(originTime ?? beginTime);
|
||||||
|
_selectedDestinationDate =
|
||||||
|
destinationTime ?? endTime ?? beginTime;
|
||||||
|
_selectedDestinationTime = TimeOfDay.fromDateTime(
|
||||||
|
destinationTime ?? endTime ?? beginTime,
|
||||||
|
);
|
||||||
|
_hasOriginTime = hasOriginTime;
|
||||||
|
_hasDestinationTime = hasDestinationTime;
|
||||||
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
||||||
_routeResult = restoredRouteResult;
|
_routeResult = restoredRouteResult;
|
||||||
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
|
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
|
||||||
@@ -319,6 +405,10 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
_networkController.text = (payload['leg_network'] as String? ?? '')
|
_networkController.text = (payload['leg_network'] as String? ?? '')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
_notesController.text = payload['leg_notes'] ?? '';
|
_notesController.text = payload['leg_notes'] ?? '';
|
||||||
|
_originController.text = origin;
|
||||||
|
_destinationController.text = destination;
|
||||||
|
_beginDelayController.text = beginDelay.toString();
|
||||||
|
_endDelayController.text = endDelay.toString();
|
||||||
|
|
||||||
if (useManual) {
|
if (useManual) {
|
||||||
_startController.text = payload['leg_start'] ?? '';
|
_startController.text = payload['leg_start'] ?? '';
|
||||||
@@ -359,6 +449,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
includeTimestamp: false,
|
includeTimestamp: false,
|
||||||
);
|
);
|
||||||
_restoringDraft = false;
|
_restoringDraft = false;
|
||||||
|
_scheduleMatchUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDraft() async {
|
Future<void> _loadDraft() async {
|
||||||
|
|||||||
@@ -14,15 +14,32 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
TimeOfDay _selectedTime = TimeOfDay.now();
|
TimeOfDay _selectedTime = TimeOfDay.now();
|
||||||
|
DateTime _selectedEndDate = DateTime.now();
|
||||||
|
TimeOfDay _selectedEndTime = TimeOfDay.now();
|
||||||
final _startController = TextEditingController();
|
final _startController = TextEditingController();
|
||||||
final _endController = TextEditingController();
|
final _endController = TextEditingController();
|
||||||
final _headcodeController = TextEditingController();
|
final _headcodeController = TextEditingController();
|
||||||
final _notesController = TextEditingController();
|
final _notesController = TextEditingController();
|
||||||
final _mileageController = TextEditingController();
|
final _mileageController = TextEditingController();
|
||||||
final _networkController = TextEditingController();
|
final _networkController = TextEditingController();
|
||||||
|
final _originController = TextEditingController();
|
||||||
|
final _destinationController = TextEditingController();
|
||||||
|
final _beginDelayController = TextEditingController(text: '0');
|
||||||
|
final _endDelayController = TextEditingController(text: '0');
|
||||||
|
DateTime _selectedOriginDate = DateTime.now();
|
||||||
|
DateTime _selectedDestinationDate = DateTime.now();
|
||||||
|
TimeOfDay _selectedOriginTime = TimeOfDay.now();
|
||||||
|
TimeOfDay _selectedDestinationTime = TimeOfDay.now();
|
||||||
|
bool _hasOriginTime = false;
|
||||||
|
bool _hasDestinationTime = false;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
bool _useManualMileage = false;
|
bool _useManualMileage = false;
|
||||||
|
bool _hasEndTime = false;
|
||||||
|
bool _matchOriginToEntry = false;
|
||||||
|
bool _matchDestinationToEntry = false;
|
||||||
RouteResult? _routeResult;
|
RouteResult? _routeResult;
|
||||||
|
List<Station> _stations = const [];
|
||||||
|
bool _loadingStations = false;
|
||||||
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
||||||
int? _selectedTripId;
|
int? _selectedTripId;
|
||||||
bool _restoringDraft = false;
|
bool _restoringDraft = false;
|
||||||
@@ -50,9 +67,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
data.fetchClassList();
|
data.fetchClassList();
|
||||||
data.fetchTripOptions();
|
data.fetchTripOptions();
|
||||||
|
_loadStations();
|
||||||
if (_draftPersistenceEnabled) {
|
if (_draftPersistenceEnabled) {
|
||||||
_loadDraft();
|
_loadDraft();
|
||||||
}
|
}
|
||||||
|
_loadStations();
|
||||||
if (_isEditing && widget.editLegId != null) {
|
if (_isEditing && widget.editLegId != null) {
|
||||||
_loadLegForEdit(widget.editLegId!);
|
_loadLegForEdit(widget.editLegId!);
|
||||||
}
|
}
|
||||||
@@ -68,6 +87,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
_mileageController.dispose();
|
_mileageController.dispose();
|
||||||
_networkController.dispose();
|
_networkController.dispose();
|
||||||
|
_originController.dispose();
|
||||||
|
_destinationController.dispose();
|
||||||
|
_beginDelayController.dispose();
|
||||||
|
_endDelayController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +159,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()),
|
onPressed: () =>
|
||||||
|
Navigator.of(dialogContext).pop(controller.text.trim()),
|
||||||
child: const Text('Add'),
|
child: const Text('Add'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -147,15 +171,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result != null && result.isNotEmpty) {
|
if (result != null && result.isNotEmpty) {
|
||||||
final api = context.read<ApiService>();
|
final api = context.read<ApiService>();
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
try {
|
try {
|
||||||
final encoded = Uri.encodeComponent(result);
|
final encoded = Uri.encodeComponent(result);
|
||||||
final res = await api.put('/trips/new?trip_name=$encoded', {});
|
final res = await api.put('/trips/new?trip_name=$encoded', {});
|
||||||
await data.fetchTripOptions();
|
await data.fetchTripOptions();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final trips = data.tripList;
|
final trips = data.tripList;
|
||||||
final apiTripId = res is Map ? res['trip_id'] as int? : null;
|
final apiTripId = res is Map ? res['trip_id'] as int? : null;
|
||||||
TripSummary match;
|
TripSummary match;
|
||||||
try {
|
try {
|
||||||
@@ -206,6 +230,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_useManualMileage = false;
|
_useManualMileage = false;
|
||||||
});
|
});
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +243,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
);
|
);
|
||||||
if (picked != null) setState(() => _selectedDate = picked);
|
if (picked != null) setState(() => _selectedDate = picked);
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickTime() async {
|
Future<void> _pickTime() async {
|
||||||
@@ -229,6 +255,158 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
setState(() => _selectedTime = picked);
|
setState(() => _selectedTime = picked);
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
}
|
}
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickEndDate() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedEndDate,
|
||||||
|
firstDate: DateTime(1970),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => _selectedEndDate = picked);
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickEndTime() async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _selectedEndTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedEndTime = picked);
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleEndTime(bool? value) {
|
||||||
|
final useEndTime = value ?? false;
|
||||||
|
final wasEnabled = _hasEndTime;
|
||||||
|
setState(() {
|
||||||
|
_hasEndTime = useEndTime;
|
||||||
|
if (useEndTime && !wasEnabled) {
|
||||||
|
_selectedEndDate = _selectedDate;
|
||||||
|
_selectedEndTime = _selectedTime;
|
||||||
|
if (_endDelayController.text.isEmpty) {
|
||||||
|
_endDelayController.text = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleMatchOrigin(bool? value) {
|
||||||
|
final enabled = value ?? false;
|
||||||
|
setState(() {
|
||||||
|
_matchOriginToEntry = enabled;
|
||||||
|
if (enabled) _hasOriginTime = true;
|
||||||
|
});
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleMatchDestination(bool? value) {
|
||||||
|
final enabled = value ?? false;
|
||||||
|
setState(() {
|
||||||
|
_matchDestinationToEntry = enabled;
|
||||||
|
if (enabled) _hasDestinationTime = true;
|
||||||
|
});
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickOriginDate() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedOriginDate,
|
||||||
|
firstDate: DateTime(1970),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => _selectedOriginDate = picked);
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickOriginTime() async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _selectedOriginTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedOriginTime = picked);
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDestinationDate() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedDestinationDate,
|
||||||
|
firstDate: DateTime(1970),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => _selectedDestinationDate = picked);
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDestinationTime() async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _selectedDestinationTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedDestinationTime = picked);
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleOriginTime(bool? value) {
|
||||||
|
final enabled = value ?? false;
|
||||||
|
setState(() {
|
||||||
|
_hasOriginTime = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
_selectedOriginDate = _selectedDate;
|
||||||
|
_selectedOriginTime = _selectedTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleDestinationTime(bool? value) {
|
||||||
|
final enabled = value ?? false;
|
||||||
|
setState(() {
|
||||||
|
_hasDestinationTime = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
_selectedDestinationDate = _selectedEndDate;
|
||||||
|
_selectedDestinationTime = _selectedEndTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStations() async {
|
||||||
|
if (_loadingStations) return;
|
||||||
|
setState(() => _loadingStations = true);
|
||||||
|
try {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
await data.fetchStationFilters();
|
||||||
|
final stations = await data.fetchStations();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _stations = stations);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to load stations: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingStations = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLegForEdit(int legId) async {
|
Future<void> _loadLegForEdit(int legId) async {
|
||||||
@@ -245,6 +423,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
}
|
}
|
||||||
final beginTime =
|
final beginTime =
|
||||||
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
|
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
|
||||||
|
final endTime = DateTime.tryParse(json['leg_end_time'] ?? '');
|
||||||
final routeStations = _parseRouteStations(json['leg_route']);
|
final routeStations = _parseRouteStations(json['leg_route']);
|
||||||
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
||||||
final useManual = routeStations.isEmpty;
|
final useManual = routeStations.isEmpty;
|
||||||
@@ -262,6 +441,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
.map((e) => Map<String, dynamic>.from(e))
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
final beginDelay = (json['leg_begin_delay'] as num?)?.toInt() ?? 0;
|
||||||
|
final endDelay = (json['leg_end_delay'] as num?)?.toInt() ?? 0;
|
||||||
|
final origin = json['leg_origin'] as String? ?? '';
|
||||||
|
final destination = json['leg_destination'] as String? ?? '';
|
||||||
|
final hasEndTime = endTime != null || endDelay != 0;
|
||||||
|
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
|
||||||
|
final destinationTime =
|
||||||
|
DateTime.tryParse(json['leg_destination_time'] ?? '');
|
||||||
|
final hasOriginTime = originTime != null;
|
||||||
|
final hasDestinationTime = destinationTime != null;
|
||||||
|
|
||||||
_restoringDraft = true;
|
_restoringDraft = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -270,6 +459,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
||||||
_selectedDate = beginTime;
|
_selectedDate = beginTime;
|
||||||
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
||||||
|
_selectedEndDate = endTime ?? beginTime;
|
||||||
|
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
|
||||||
|
_hasEndTime = hasEndTime;
|
||||||
|
_selectedOriginDate = originTime ?? beginTime;
|
||||||
|
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
||||||
|
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
||||||
|
_selectedDestinationTime =
|
||||||
|
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
|
||||||
|
_hasOriginTime = hasOriginTime;
|
||||||
|
_hasDestinationTime = hasDestinationTime;
|
||||||
_useManualMileage = useManual;
|
_useManualMileage = useManual;
|
||||||
_routeResult = routeResult;
|
_routeResult = routeResult;
|
||||||
_startController.text = json['leg_start'] ?? '';
|
_startController.text = json['leg_start'] ?? '';
|
||||||
@@ -279,6 +478,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_notesController.text = json['leg_notes'] ?? '';
|
_notesController.text = json['leg_notes'] ?? '';
|
||||||
_networkController.text = (json['leg_network'] as String? ?? '')
|
_networkController.text = (json['leg_network'] as String? ?? '')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
_originController.text = origin;
|
||||||
|
_destinationController.text = destination;
|
||||||
|
_beginDelayController.text = beginDelay.toString();
|
||||||
|
_endDelayController.text = endDelay.toString();
|
||||||
_mileageController.text = mileageVal == 0
|
_mileageController.text = mileageVal == 0
|
||||||
? ''
|
? ''
|
||||||
: mileageVal.toStringAsFixed(2);
|
: mileageVal.toStringAsFixed(2);
|
||||||
@@ -357,6 +560,161 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedTime.minute,
|
_selectedTime.minute,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DateTime? get _legEndDateTime {
|
||||||
|
if (!_hasEndTime) return null;
|
||||||
|
return DateTime(
|
||||||
|
_selectedEndDate.year,
|
||||||
|
_selectedEndDate.month,
|
||||||
|
_selectedEndDate.day,
|
||||||
|
_selectedEndTime.hour,
|
||||||
|
_selectedEndTime.minute,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? get _originDateTime {
|
||||||
|
if (!_hasOriginTime) return null;
|
||||||
|
return DateTime(
|
||||||
|
_selectedOriginDate.year,
|
||||||
|
_selectedOriginDate.month,
|
||||||
|
_selectedOriginDate.day,
|
||||||
|
_selectedOriginTime.hour,
|
||||||
|
_selectedOriginTime.minute,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? get _destinationDateTime {
|
||||||
|
if (!_hasDestinationTime) return null;
|
||||||
|
return DateTime(
|
||||||
|
_selectedDestinationDate.year,
|
||||||
|
_selectedDestinationDate.month,
|
||||||
|
_selectedDestinationDate.day,
|
||||||
|
_selectedDestinationTime.hour,
|
||||||
|
_selectedDestinationTime.minute,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _parseDelayMinutes(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
final parsed = int.tryParse(trimmed);
|
||||||
|
return parsed ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _derivedStartLocation() {
|
||||||
|
if (_useManualMileage) return _startController.text.trim();
|
||||||
|
final routeStations = _routeResult?.calculatedRoute ?? [];
|
||||||
|
if (routeStations.isNotEmpty) return routeStations.first;
|
||||||
|
return _startController.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _derivedEndLocation() {
|
||||||
|
if (_useManualMileage) return _endController.text.trim();
|
||||||
|
final routeStations = _routeResult?.calculatedRoute ?? [];
|
||||||
|
if (routeStations.isNotEmpty) return routeStations.last;
|
||||||
|
return _endController.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyMatchSelections() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!(_matchOriginToEntry || _matchDestinationToEntry)) return;
|
||||||
|
setState(() {
|
||||||
|
if (_matchOriginToEntry) {
|
||||||
|
final startVal = _derivedStartLocation();
|
||||||
|
if (_originController.text != startVal) {
|
||||||
|
_originController.text = startVal;
|
||||||
|
}
|
||||||
|
if (_hasOriginTime) {
|
||||||
|
final startTime = _legDateTime;
|
||||||
|
_selectedOriginDate = DateTime(
|
||||||
|
startTime.year,
|
||||||
|
startTime.month,
|
||||||
|
startTime.day,
|
||||||
|
);
|
||||||
|
_selectedOriginTime = TimeOfDay.fromDateTime(startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_matchDestinationToEntry) {
|
||||||
|
final endVal = _derivedEndLocation();
|
||||||
|
if (_destinationController.text != endVal) {
|
||||||
|
_destinationController.text = endVal;
|
||||||
|
}
|
||||||
|
if (_hasDestinationTime) {
|
||||||
|
final endTime = _legEndDateTime ?? _legDateTime;
|
||||||
|
_selectedDestinationDate = DateTime(
|
||||||
|
endTime.year,
|
||||||
|
endTime.month,
|
||||||
|
endTime.day,
|
||||||
|
);
|
||||||
|
_selectedDestinationTime = TimeOfDay.fromDateTime(endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchUpdateScheduled = false;
|
||||||
|
|
||||||
|
void _scheduleMatchUpdate() {
|
||||||
|
if (!(_matchOriginToEntry || _matchDestinationToEntry)) return;
|
||||||
|
if (_matchUpdateScheduled) return;
|
||||||
|
_matchUpdateScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_matchUpdateScheduled = false;
|
||||||
|
_applyMatchSelections();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _trainLocationBlock({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required bool hasTime,
|
||||||
|
required ValueChanged<bool?>? onTimeChanged,
|
||||||
|
required String matchLabel,
|
||||||
|
required bool matchValue,
|
||||||
|
required ValueChanged<bool?>? onMatchChanged,
|
||||||
|
required Widget Function() pickerBuilder,
|
||||||
|
}) {
|
||||||
|
final matchInfo = matchValue
|
||||||
|
? Text(
|
||||||
|
'$label set to entry ${label == 'Origin' ? 'start' : 'end'}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CheckboxListTile(
|
||||||
|
value: matchValue,
|
||||||
|
onChanged: onMatchChanged,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(matchLabel),
|
||||||
|
),
|
||||||
|
if (matchInfo != null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12.0, bottom: 4),
|
||||||
|
child: matchInfo,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (!matchValue) ...[
|
||||||
|
_stationField(
|
||||||
|
label: label,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: hasTime,
|
||||||
|
onChanged: onTimeChanged,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text('Add $label time'),
|
||||||
|
),
|
||||||
|
if (hasTime) pickerBuilder(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget body;
|
Widget body;
|
||||||
@@ -376,7 +734,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
|
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
|
||||||
final balancedHeight = balancePanels ? 165.0 : null;
|
final balancedHeight = balancePanels ? 165.0 : null;
|
||||||
|
|
||||||
final detailPanel = _section('Details', [
|
final entryPanel = _section('Entry', [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
@@ -424,31 +782,46 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_buildTripSelector(context),
|
_buildTripSelector(context),
|
||||||
|
_dateTimeGroup(
|
||||||
|
context,
|
||||||
|
title: 'Departure time',
|
||||||
|
onDateTap: _pickDate,
|
||||||
|
onTimeTap: _pickTime,
|
||||||
|
selectedDate: _selectedDate,
|
||||||
|
selectedTime: _selectedTime,
|
||||||
|
delayController: _beginDelayController,
|
||||||
|
singleColumn: isMobile,
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Checkbox(
|
||||||
child: OutlinedButton.icon(
|
value: _hasEndTime,
|
||||||
onPressed: _pickDate,
|
onChanged: _submitting ? null : _toggleEndTime,
|
||||||
icon: const Icon(Icons.calendar_today),
|
|
||||||
label: Text(DateFormat.yMMMd().format(_selectedDate)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: _pickTime,
|
|
||||||
icon: const Icon(Icons.schedule),
|
|
||||||
label: Text(_selectedTime.format(context)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const Text('Add arrival time'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_hasEndTime)
|
||||||
|
_dateTimeGroup(
|
||||||
|
context,
|
||||||
|
title: 'Arrival time',
|
||||||
|
onDateTap: _pickEndDate,
|
||||||
|
onTimeTap: _pickEndTime,
|
||||||
|
selectedDate: _selectedEndDate,
|
||||||
|
selectedTime: _selectedEndTime,
|
||||||
|
delayController: _endDelayController,
|
||||||
|
singleColumn: isMobile,
|
||||||
|
),
|
||||||
if (_useManualMileage)
|
if (_useManualMileage)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _startController,
|
controller: _startController,
|
||||||
|
onChanged: (_) {
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'From',
|
labelText: 'From',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -462,6 +835,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _endController,
|
controller: _endController,
|
||||||
|
onChanged: (_) {
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'To',
|
labelText: 'To',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -473,6 +850,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final trainPanel = _section('Train', [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _headcodeController,
|
controller: _headcodeController,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
@@ -482,6 +862,43 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_trainLocationBlock(
|
||||||
|
label: 'Origin',
|
||||||
|
controller: _originController,
|
||||||
|
hasTime: _hasOriginTime,
|
||||||
|
onTimeChanged: _submitting ? null : _toggleOriginTime,
|
||||||
|
matchLabel: 'Match entry start',
|
||||||
|
matchValue: _matchOriginToEntry,
|
||||||
|
onMatchChanged: _submitting ? null : _toggleMatchOrigin,
|
||||||
|
pickerBuilder: () => _dateTimeGroupSimple(
|
||||||
|
context,
|
||||||
|
title: 'Origin departure',
|
||||||
|
onDateTap: _pickOriginDate,
|
||||||
|
onTimeTap: _pickOriginTime,
|
||||||
|
selectedDate: _selectedOriginDate,
|
||||||
|
selectedTime: _selectedOriginTime,
|
||||||
|
singleColumn: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_trainLocationBlock(
|
||||||
|
label: 'Destination',
|
||||||
|
controller: _destinationController,
|
||||||
|
hasTime: _hasDestinationTime,
|
||||||
|
onTimeChanged: _submitting ? null : _toggleDestinationTime,
|
||||||
|
matchLabel: 'Match entry end',
|
||||||
|
matchValue: _matchDestinationToEntry,
|
||||||
|
onMatchChanged:
|
||||||
|
_submitting ? null : _toggleMatchDestination,
|
||||||
|
pickerBuilder: () => _dateTimeGroupSimple(
|
||||||
|
context,
|
||||||
|
title: 'Destination arrival',
|
||||||
|
onDateTap: _pickDestinationDate,
|
||||||
|
onTimeTap: _pickDestinationTime,
|
||||||
|
selectedDate: _selectedDestinationDate,
|
||||||
|
selectedTime: _selectedDestinationTime,
|
||||||
|
singleColumn: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _networkController,
|
controller: _networkController,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
@@ -519,14 +936,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (!_useManualMileage)
|
if (!_useManualMileage)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: ElevatedButton.icon(
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
minimumSize: const Size(0, 32),
|
|
||||||
),
|
|
||||||
onPressed: _openCalculator,
|
onPressed: _openCalculator,
|
||||||
icon: const Icon(Icons.calculate, size: 18),
|
icon: const Icon(Icons.calculate, size: 18),
|
||||||
label: const Text('Open mileage calculator'),
|
label: const Text('Open mileage calculator'),
|
||||||
@@ -565,6 +975,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
onSelected: (val) {
|
onSelected: (val) {
|
||||||
setState(() => _useManualMileage = val);
|
setState(() => _useManualMileage = val);
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
minHeight: balancedHeight,
|
minHeight: balancedHeight,
|
||||||
@@ -575,7 +986,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
detailPanel,
|
entryPanel,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
trainPanel,
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
twoCol
|
twoCol
|
||||||
? Row(
|
? Row(
|
||||||
@@ -736,6 +1149,150 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _delayField(
|
||||||
|
TextEditingController controller, {
|
||||||
|
required String label,
|
||||||
|
bool expand = false,
|
||||||
|
}) {
|
||||||
|
final field = TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(signed: true),
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixText: 'min',
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
onChanged: (_) => _saveDraft(),
|
||||||
|
);
|
||||||
|
if (expand) return field;
|
||||||
|
return SizedBox(width: 150, child: field);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stationField({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
}) {
|
||||||
|
final stationNames = _stations
|
||||||
|
.map((s) => s.name.trim())
|
||||||
|
.where((name) => name.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
if (stationNames.isEmpty) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: _loadingStations
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(4.0),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (_) => _saveDraft(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<String> optionsBuilder(TextEditingValue value) {
|
||||||
|
final query = value.text.trim();
|
||||||
|
if (query.isEmpty) return const Iterable<String>.empty();
|
||||||
|
return _matchStations(query, stationNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Autocomplete<String>(
|
||||||
|
optionsBuilder: optionsBuilder,
|
||||||
|
onSelected: (selection) {
|
||||||
|
controller.text = selection;
|
||||||
|
_saveDraft();
|
||||||
|
},
|
||||||
|
fieldViewBuilder:
|
||||||
|
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
|
if (textEditingController.text != controller.text) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (textEditingController.text != controller.text) {
|
||||||
|
textEditingController.value = controller.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return TextFormField(
|
||||||
|
controller: textEditingController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: _loadingStations
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(4.0),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) {
|
||||||
|
controller.value = textEditingController.value;
|
||||||
|
_saveDraft();
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (_) {
|
||||||
|
final matches = _matchStations(
|
||||||
|
textEditingController.text,
|
||||||
|
stationNames,
|
||||||
|
).toList();
|
||||||
|
if (matches.isNotEmpty) {
|
||||||
|
final top = matches.first;
|
||||||
|
controller.text = top;
|
||||||
|
textEditingController.text = top;
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
focusNode.unfocus();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<String> _matchStations(String rawQuery, List<String> stationNames) {
|
||||||
|
final query = rawQuery.toLowerCase();
|
||||||
|
final best = <String>[];
|
||||||
|
for (final name in stationNames) {
|
||||||
|
if (!name.toLowerCase().contains(query)) continue;
|
||||||
|
_insertCandidate(best, name, max: 10);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertCandidate(List<String> best, String candidate, {required int max}) {
|
||||||
|
final existingIndex = best.indexOf(candidate);
|
||||||
|
if (existingIndex >= 0) return;
|
||||||
|
|
||||||
|
int insertAt = 0;
|
||||||
|
while (insertAt < best.length &&
|
||||||
|
_candidateCompare(best[insertAt], candidate) <= 0) {
|
||||||
|
insertAt++;
|
||||||
|
}
|
||||||
|
best.insert(insertAt, candidate);
|
||||||
|
if (best.length > max) best.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
int _candidateCompare(String a, String b) {
|
||||||
|
final byLength = a.length.compareTo(b.length);
|
||||||
|
if (byLength != 0) return byLength;
|
||||||
|
return a.compareTo(b);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _section(
|
Widget _section(
|
||||||
String title,
|
String title,
|
||||||
List<Widget> children, {
|
List<Widget> children, {
|
||||||
@@ -781,6 +1338,154 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _dateTimeGroup(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required VoidCallback onDateTap,
|
||||||
|
required VoidCallback onTimeTap,
|
||||||
|
required DateTime selectedDate,
|
||||||
|
required TimeOfDay selectedTime,
|
||||||
|
required TextEditingController delayController,
|
||||||
|
bool singleColumn = false,
|
||||||
|
}) {
|
||||||
|
final headerStyle = Theme.of(context).textTheme.labelLarge;
|
||||||
|
final dateButton = OutlinedButton.icon(
|
||||||
|
onPressed: onDateTap,
|
||||||
|
icon: const Icon(Icons.calendar_today),
|
||||||
|
label: Text(DateFormat.yMMMd().format(selectedDate)),
|
||||||
|
);
|
||||||
|
final timeButton = OutlinedButton.icon(
|
||||||
|
onPressed: onTimeTap,
|
||||||
|
icon: const Icon(Icons.schedule),
|
||||||
|
label: Text(selectedTime.format(context)),
|
||||||
|
);
|
||||||
|
final delayField = _delayField(
|
||||||
|
delayController,
|
||||||
|
label: 'Delay',
|
||||||
|
expand: singleColumn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (singleColumn) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: headerStyle),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SizedBox(width: double.infinity, child: dateButton),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(width: double.infinity, child: timeButton),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(width: double.infinity, child: delayField),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: headerStyle),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: dateButton),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: timeButton),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
delayField,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateTimeGroupSimple(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required VoidCallback onDateTap,
|
||||||
|
required VoidCallback onTimeTap,
|
||||||
|
required DateTime selectedDate,
|
||||||
|
required TimeOfDay selectedTime,
|
||||||
|
bool singleColumn = true,
|
||||||
|
}) {
|
||||||
|
final headerStyle = Theme.of(context).textTheme.labelLarge;
|
||||||
|
final dateButton = OutlinedButton.icon(
|
||||||
|
onPressed: onDateTap,
|
||||||
|
icon: const Icon(Icons.calendar_today),
|
||||||
|
label: Text(DateFormat.yMMMd().format(selectedDate)),
|
||||||
|
);
|
||||||
|
final timeButton = OutlinedButton.icon(
|
||||||
|
onPressed: onTimeTap,
|
||||||
|
icon: const Icon(Icons.schedule),
|
||||||
|
label: Text(selectedTime.format(context)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (singleColumn) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: headerStyle),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SizedBox(width: double.infinity, child: dateButton),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(width: double.infinity, child: timeButton),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: headerStyle),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: dateButton),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: timeButton),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _timeToggleBlock({
|
||||||
|
required String label,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool?>? onChanged,
|
||||||
|
required String matchLabel,
|
||||||
|
required bool matchValue,
|
||||||
|
required ValueChanged<bool?>? onMatchChanged,
|
||||||
|
required bool showMatch,
|
||||||
|
Widget? picker,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CheckboxListTile(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(label),
|
||||||
|
),
|
||||||
|
if (showMatch)
|
||||||
|
CheckboxListTile(
|
||||||
|
value: matchValue,
|
||||||
|
onChanged: onMatchChanged,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.only(left: 12),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(matchLabel),
|
||||||
|
),
|
||||||
|
if (picker != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
picker,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpperCaseTextFormatter extends TextInputFormatter {
|
class _UpperCaseTextFormatter extends TextInputFormatter {
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
? double.tryParse(_mileageController.text.trim()) ?? 0
|
? double.tryParse(_mileageController.text.trim()) ?? 0
|
||||||
: (_routeResult?.distance ?? 0);
|
: (_routeResult?.distance ?? 0);
|
||||||
final tractionPayload = _buildTractionPayload();
|
final tractionPayload = _buildTractionPayload();
|
||||||
|
final endTime = _legEndDateTime;
|
||||||
|
final originTime = _originDateTime;
|
||||||
|
final destinationTime = _destinationDateTime;
|
||||||
|
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
|
||||||
|
final endDelay =
|
||||||
|
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
|
||||||
final snapshot = _buildSubmissionSnapshot(
|
final snapshot = _buildSubmissionSnapshot(
|
||||||
routeStations: routeStations,
|
routeStations: routeStations,
|
||||||
startVal: startVal,
|
startVal: startVal,
|
||||||
@@ -82,19 +88,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
final isEditingExisting = _isEditing && widget.editLegId != null;
|
final isEditingExisting = _isEditing && widget.editLegId != null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final commonPayload = {
|
||||||
|
if (isEditingExisting) "leg_id": widget.editLegId,
|
||||||
|
"leg_trip": _selectedTripId,
|
||||||
|
"leg_begin_time": _legDateTime.toIso8601String(),
|
||||||
|
if (endTime != null) "leg_end_time": endTime.toIso8601String(),
|
||||||
|
if (originTime != null)
|
||||||
|
"leg_origin_time": originTime.toIso8601String(),
|
||||||
|
if (destinationTime != null)
|
||||||
|
"leg_destination_time": destinationTime.toIso8601String(),
|
||||||
|
"leg_notes": _notesController.text.trim(),
|
||||||
|
"leg_headcode": _headcodeController.text.trim(),
|
||||||
|
"leg_network": _networkController.text.trim(),
|
||||||
|
"leg_origin": _originController.text.trim(),
|
||||||
|
"leg_destination": _destinationController.text.trim(),
|
||||||
|
"leg_begin_delay": beginDelay,
|
||||||
|
if (_hasEndTime) "leg_end_delay": endDelay,
|
||||||
|
"locos": tractionPayload,
|
||||||
|
};
|
||||||
if (_useManualMileage) {
|
if (_useManualMileage) {
|
||||||
final body = {
|
final body = {
|
||||||
if (isEditingExisting) "leg_id": widget.editLegId,
|
...commonPayload,
|
||||||
"leg_trip": _selectedTripId,
|
|
||||||
"leg_start": startVal,
|
"leg_start": startVal,
|
||||||
"leg_end": endVal,
|
"leg_end": endVal,
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"leg_distance": mileageVal,
|
"leg_distance": mileageVal,
|
||||||
"isKilometers": false,
|
"isKilometers": false,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
};
|
};
|
||||||
if (isEditingExisting) {
|
if (isEditingExisting) {
|
||||||
await api.put('/update', body);
|
await api.put('/update', body);
|
||||||
@@ -103,14 +121,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final body = {
|
final body = {
|
||||||
if (isEditingExisting) "leg_id": widget.editLegId,
|
...commonPayload,
|
||||||
"leg_trip": _selectedTripId,
|
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_route": routeStations,
|
"leg_route": routeStations,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
};
|
};
|
||||||
if (isEditingExisting) {
|
if (isEditingExisting) {
|
||||||
await api.put('/update', body);
|
await api.put('/update', body);
|
||||||
@@ -120,6 +132,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
dataService.refreshLegs();
|
dataService.refreshLegs();
|
||||||
|
await dataService.fetchNotifications();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -128,7 +141,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')),
|
||||||
@@ -145,18 +160,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
required double mileageVal,
|
required double mileageVal,
|
||||||
required List<Map<String, dynamic>> tractionPayload,
|
required List<Map<String, dynamic>> tractionPayload,
|
||||||
}) {
|
}) {
|
||||||
|
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
|
||||||
|
final endDelay =
|
||||||
|
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
|
||||||
return {
|
return {
|
||||||
"legId": widget.editLegId,
|
"legId": widget.editLegId,
|
||||||
"useManualMileage": _useManualMileage,
|
"useManualMileage": _useManualMileage,
|
||||||
"tripId": _selectedTripId,
|
"tripId": _selectedTripId,
|
||||||
"legDateTime": _legDateTime.toIso8601String(),
|
"legDateTime": _legDateTime.toIso8601String(),
|
||||||
|
"legEndTime": _legEndDateTime?.toIso8601String(),
|
||||||
|
"hasEndTime": _hasEndTime,
|
||||||
|
"legOriginTime": _originDateTime?.toIso8601String(),
|
||||||
|
"hasOriginTime": _hasOriginTime,
|
||||||
|
"legDestinationTime": _destinationDateTime?.toIso8601String(),
|
||||||
|
"hasDestinationTime": _hasDestinationTime,
|
||||||
"start": startVal,
|
"start": startVal,
|
||||||
"end": endVal,
|
"end": endVal,
|
||||||
|
"origin": _originController.text.trim(),
|
||||||
|
"destination": _destinationController.text.trim(),
|
||||||
"routeStations": routeStations,
|
"routeStations": routeStations,
|
||||||
"mileage": mileageVal,
|
"mileage": mileageVal,
|
||||||
"network": _networkController.text.trim(),
|
"network": _networkController.text.trim(),
|
||||||
"notes": _notesController.text.trim(),
|
"notes": _notesController.text.trim(),
|
||||||
"headcode": _headcodeController.text.trim(),
|
"headcode": _headcodeController.text.trim(),
|
||||||
|
"beginDelay": beginDelay,
|
||||||
|
"endDelay": endDelay,
|
||||||
"locos": tractionPayload,
|
"locos": tractionPayload,
|
||||||
"routeResult": _routeResult == null
|
"routeResult": _routeResult == null
|
||||||
? null
|
? null
|
||||||
@@ -199,11 +227,27 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
_notesController.clear();
|
_notesController.clear();
|
||||||
_mileageController.clear();
|
_mileageController.clear();
|
||||||
_networkController.clear();
|
_networkController.clear();
|
||||||
|
_originController.clear();
|
||||||
|
_destinationController.clear();
|
||||||
|
_beginDelayController.text = '0';
|
||||||
|
_endDelayController.text = '0';
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_setState(() {
|
_setState(() {
|
||||||
_selectedDate = now;
|
_selectedDate = now;
|
||||||
_selectedTime = TimeOfDay.fromDateTime(now);
|
_selectedTime = TimeOfDay.fromDateTime(now);
|
||||||
|
_selectedEndDate = now;
|
||||||
|
_selectedEndTime = TimeOfDay.fromDateTime(now);
|
||||||
|
_selectedOriginDate = now;
|
||||||
|
_selectedOriginTime = TimeOfDay.fromDateTime(now);
|
||||||
|
_selectedDestinationDate = now;
|
||||||
|
_selectedDestinationTime = TimeOfDay.fromDateTime(now);
|
||||||
_useManualMileage = false;
|
_useManualMileage = false;
|
||||||
|
_hasEndTime = false;
|
||||||
|
_hasOriginTime = false;
|
||||||
|
_hasDestinationTime = false;
|
||||||
|
_matchOriginToEntry = false;
|
||||||
|
_matchDestinationToEntry = false;
|
||||||
|
_matchUpdateScheduled = false;
|
||||||
_routeResult = null;
|
_routeResult = null;
|
||||||
_tractionItems
|
_tractionItems
|
||||||
..clear()
|
..clear()
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
705
lib/components/pages/profile.dart
Normal file
705
lib/components/pages/profile.dart
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class ProfilePage extends StatefulWidget {
|
||||||
|
const ProfilePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfilePage> createState() => _ProfilePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfilePageState extends State<ProfilePage> {
|
||||||
|
bool _initialised = false;
|
||||||
|
final Map<String, bool> _groupExpanded = {};
|
||||||
|
bool _loadingAwards = false;
|
||||||
|
bool _loadingClassProgress = false;
|
||||||
|
bool _loadingLocoProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_initialised) return;
|
||||||
|
_initialised = true;
|
||||||
|
_refreshAwards();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshAwards() {
|
||||||
|
_loadingAwards = false;
|
||||||
|
_loadingClassProgress = false;
|
||||||
|
_loadingLocoProgress = false;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
return Future.wait([
|
||||||
|
data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'),
|
||||||
|
data.fetchClassClearanceProgress(),
|
||||||
|
data.fetchLocoClearanceProgress(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.watch<DataService>();
|
||||||
|
final awards = data.badgeAwards;
|
||||||
|
final loading = data.isBadgeAwardsLoading;
|
||||||
|
final classProgress = data.classClearanceProgress;
|
||||||
|
final classProgressLoading =
|
||||||
|
data.isClassClearanceProgressLoading || _loadingClassProgress;
|
||||||
|
final locoProgress = data.locoClearanceProgress;
|
||||||
|
final locoProgressLoading =
|
||||||
|
data.isLocoClearanceProgressLoading || _loadingLocoProgress;
|
||||||
|
final hasAnyData =
|
||||||
|
awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Badges'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.pop();
|
||||||
|
} else {
|
||||||
|
context.go('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _refreshAwards,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if ((loading || classProgressLoading || locoProgressLoading) &&
|
||||||
|
!hasAnyData)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (!hasAnyData)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: Text('No badges awarded yet.'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._buildGroupedAwards(
|
||||||
|
context,
|
||||||
|
awards,
|
||||||
|
classProgress,
|
||||||
|
locoProgress,
|
||||||
|
classProgressLoading,
|
||||||
|
locoProgressLoading,
|
||||||
|
data.classClearanceHasMore,
|
||||||
|
data.locoClearanceHasMore,
|
||||||
|
data.badgeAwardsHasMore,
|
||||||
|
loading,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildGroupedAwards(
|
||||||
|
BuildContext context,
|
||||||
|
List<BadgeAward> awards,
|
||||||
|
List<ClassClearanceProgress> classProgress,
|
||||||
|
List<LocoClearanceProgress> locoProgress,
|
||||||
|
bool classProgressLoading,
|
||||||
|
bool locoProgressLoading,
|
||||||
|
bool classProgressHasMore,
|
||||||
|
bool locoProgressHasMore,
|
||||||
|
bool badgeAwardsHasMore,
|
||||||
|
bool badgeAwardsLoading,
|
||||||
|
) {
|
||||||
|
final grouped = _groupAwards(awards);
|
||||||
|
if ((classProgress.isNotEmpty || classProgressLoading) &&
|
||||||
|
!grouped.containsKey('class_clearance')) {
|
||||||
|
grouped['class_clearance'] = [];
|
||||||
|
}
|
||||||
|
if ((locoProgress.isNotEmpty || locoProgressLoading) &&
|
||||||
|
!grouped.containsKey('loco_clearance')) {
|
||||||
|
grouped['loco_clearance'] = [];
|
||||||
|
}
|
||||||
|
final codes = _orderedBadgeCodes(grouped.keys.toList());
|
||||||
|
|
||||||
|
return codes.map((code) {
|
||||||
|
final items = grouped[code]!;
|
||||||
|
final expanded = _groupExpanded[code] ?? true;
|
||||||
|
final title = _formatBadgeName(code);
|
||||||
|
final isClass = code == 'class_clearance';
|
||||||
|
final isLoco = code == 'loco_clearance';
|
||||||
|
final classItems = isClass ? classProgress : <ClassClearanceProgress>[];
|
||||||
|
final locoItems = isLoco ? locoProgress : <LocoClearanceProgress>[];
|
||||||
|
final awardCount = isLoco
|
||||||
|
? locoItems.where((item) => item.awardedTiers.isNotEmpty).length
|
||||||
|
: items.length;
|
||||||
|
final isLoadingSection = isClass
|
||||||
|
? (classProgressLoading || badgeAwardsLoading || _loadingAwards)
|
||||||
|
: (isLoco ? locoProgressLoading : false);
|
||||||
|
|
||||||
|
final children = <Widget>[];
|
||||||
|
|
||||||
|
if (isClass && items.isNotEmpty) {
|
||||||
|
children.add(_buildSubheading(context, 'Awarded'));
|
||||||
|
children.addAll(
|
||||||
|
items.map(
|
||||||
|
(award) => Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||||
|
child: _buildAwardCard(context, award, compact: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) {
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||||
|
child: _buildLoadMoreButton(
|
||||||
|
context,
|
||||||
|
badgeAwardsLoading || _loadingAwards,
|
||||||
|
() => _loadMoreAwards(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!isClass && !isLoco && items.isNotEmpty) {
|
||||||
|
children.add(_buildSubheading(context, 'Awarded'));
|
||||||
|
children.addAll(
|
||||||
|
items.map(
|
||||||
|
(award) => Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||||
|
child: _buildAwardCard(context, award, compact: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClass) {
|
||||||
|
children.addAll(
|
||||||
|
_buildClassProgressSection(
|
||||||
|
context,
|
||||||
|
classItems,
|
||||||
|
classProgressLoading,
|
||||||
|
classProgressHasMore,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoco) {
|
||||||
|
children.addAll(
|
||||||
|
_buildLocoProgressSection(
|
||||||
|
context,
|
||||||
|
locoItems,
|
||||||
|
locoProgressLoading,
|
||||||
|
locoProgressHasMore,
|
||||||
|
showHeading: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.isEmpty && !isLoadingSection) {
|
||||||
|
children.add(
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: Text('No awards'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: ExpansionTile(
|
||||||
|
key: ValueKey(code),
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(title)),
|
||||||
|
if (isLoadingSection) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildCountChip(context, awardCount),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
initiallyExpanded: expanded,
|
||||||
|
onExpansionChanged: (isOpen) {
|
||||||
|
setState(() => _groupExpanded[code] = isOpen);
|
||||||
|
},
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<BadgeAward>> _groupAwards(List<BadgeAward> awards) {
|
||||||
|
final Map<String, List<BadgeAward>> grouped = {};
|
||||||
|
for (final award in awards) {
|
||||||
|
final code = award.badgeCode.toLowerCase();
|
||||||
|
grouped.putIfAbsent(code, () => []).add(award);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAwardCard(
|
||||||
|
BuildContext context,
|
||||||
|
BadgeAward award, {
|
||||||
|
bool compact = false,
|
||||||
|
}) {
|
||||||
|
final badgeName = _formatBadgeName(award.badgeCode);
|
||||||
|
final tier = award.badgeTier.isNotEmpty
|
||||||
|
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
|
||||||
|
: '';
|
||||||
|
final tierIcon = _buildTierIcon(award.badgeTier);
|
||||||
|
final scope = _scopeToShow(award);
|
||||||
|
|
||||||
|
final content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (tierIcon != null) ...[
|
||||||
|
tierIcon,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$badgeName • $tier',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (award.awardedAt != null)
|
||||||
|
Text(
|
||||||
|
_formatAwardDate(award.awardedAt!),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (scope != null && scope.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
scope,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (award.loco != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildLocoInfo(context, award.loco!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10.0),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocoInfo(BuildContext context, LocoSummary loco) {
|
||||||
|
final lines = <String>[];
|
||||||
|
final classNum = [
|
||||||
|
if (loco.locoClass.isNotEmpty) loco.locoClass,
|
||||||
|
if (loco.number.isNotEmpty) loco.number,
|
||||||
|
].join(' ');
|
||||||
|
if (classNum.isNotEmpty) lines.add(classNum);
|
||||||
|
if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!);
|
||||||
|
if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!);
|
||||||
|
if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.train, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: lines.map((line) {
|
||||||
|
return Text(
|
||||||
|
line,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBadgeName(String code) {
|
||||||
|
if (code.isEmpty) return 'Badge';
|
||||||
|
const known = {
|
||||||
|
'class_clearance': 'Class Clearance',
|
||||||
|
'loco_clearance': 'Loco Clearance',
|
||||||
|
};
|
||||||
|
final lower = code.toLowerCase();
|
||||||
|
if (known.containsKey(lower)) return known[lower]!;
|
||||||
|
final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty);
|
||||||
|
return parts
|
||||||
|
.map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _orderedBadgeCodes(List<String> codes) {
|
||||||
|
final lowerCodes = codes.map((c) => c.toLowerCase()).toSet();
|
||||||
|
final ordered = <String>[];
|
||||||
|
for (final code in ['loco_clearance', 'class_clearance']) {
|
||||||
|
if (lowerCodes.remove(code)) ordered.add(code);
|
||||||
|
}
|
||||||
|
final remaining = lowerCodes.toList()
|
||||||
|
..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b)));
|
||||||
|
ordered.addAll(remaining);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubheading(BuildContext context, String label) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildClassProgressSection(
|
||||||
|
BuildContext context,
|
||||||
|
List<ClassClearanceProgress> progress,
|
||||||
|
bool isLoading,
|
||||||
|
bool hasMore,
|
||||||
|
) {
|
||||||
|
if (progress.isEmpty && !isLoading && !hasMore) return const [];
|
||||||
|
return [
|
||||||
|
_buildSubheading(context, 'In Progress'),
|
||||||
|
...progress.map(
|
||||||
|
(item) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
|
||||||
|
child: _buildClassProgressCard(context, item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasMore || isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||||
|
child: _buildLoadMoreButton(
|
||||||
|
context,
|
||||||
|
isLoading,
|
||||||
|
() => _loadMoreClassProgress(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (progress.isNotEmpty) const SizedBox(height: 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildLocoProgressSection(
|
||||||
|
BuildContext context,
|
||||||
|
List<LocoClearanceProgress> progress,
|
||||||
|
bool isLoading,
|
||||||
|
bool hasMore,
|
||||||
|
{bool showHeading = true}
|
||||||
|
) {
|
||||||
|
if (progress.isEmpty && !isLoading && !hasMore) return const [];
|
||||||
|
return [
|
||||||
|
if (showHeading) _buildSubheading(context, 'In Progress'),
|
||||||
|
if (progress.isEmpty && isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: _buildLoadingIndicator(),
|
||||||
|
),
|
||||||
|
...progress.map(
|
||||||
|
(item) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
|
||||||
|
child: _buildLocoProgressCard(context, item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasMore || isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||||
|
child: _buildLoadMoreButton(
|
||||||
|
context,
|
||||||
|
isLoading,
|
||||||
|
() => _loadMoreLocoProgress(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (progress.isNotEmpty) const SizedBox(height: 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildClassProgressCard(
|
||||||
|
BuildContext context,
|
||||||
|
ClassClearanceProgress progress,
|
||||||
|
) {
|
||||||
|
final pct = progress.percentComplete.clamp(0, 100);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
progress.className,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${pct.toStringAsFixed(0)}%',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.total == 0 ? 0 : pct / 100,
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
if (progress.total > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
'${progress.completed}/${progress.total}',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(color: Theme.of(context).hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocoProgressCard(
|
||||||
|
BuildContext context,
|
||||||
|
LocoClearanceProgress progress,
|
||||||
|
) {
|
||||||
|
final tierIcons = progress.awardedTiers
|
||||||
|
.map((tier) => _buildTierIcon(tier, size: 18))
|
||||||
|
.whereType<Widget>()
|
||||||
|
.toList();
|
||||||
|
final reachedTopTier = progress.nextTier.isEmpty;
|
||||||
|
final pct = progress.percent.clamp(0, 100);
|
||||||
|
final nextTier = progress.nextTier.isNotEmpty
|
||||||
|
? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1)
|
||||||
|
: 'Next';
|
||||||
|
final loco = progress.loco;
|
||||||
|
final title = [
|
||||||
|
if (loco.number.isNotEmpty) loco.number,
|
||||||
|
if (loco.locoClass.isNotEmpty) loco.locoClass,
|
||||||
|
].join(' • ');
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title.isNotEmpty ? title : 'Loco',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tierIcons.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: tierIcons
|
||||||
|
.expand((icon) sync* {
|
||||||
|
yield icon;
|
||||||
|
yield const SizedBox(width: 4);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
..removeLast(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if ((loco.name ?? '').isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
loco.name ?? '',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!reachedTopTier) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.required == 0 ? 0 : pct / 100,
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
'${pct.toStringAsFixed(0)}% to $nextTier award',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadMoreButton(
|
||||||
|
BuildContext context,
|
||||||
|
bool isLoading,
|
||||||
|
Future<void> Function() onPressed,
|
||||||
|
) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: isLoading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
onPressed();
|
||||||
|
},
|
||||||
|
icon: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.expand_more),
|
||||||
|
label: Text(isLoading ? 'Loading...' : 'Load more'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingIndicator() {
|
||||||
|
return const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCountChip(BuildContext context, int count) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreClassProgress() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isClassClearanceProgressLoading || _loadingClassProgress) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
setState(() => _loadingClassProgress = true);
|
||||||
|
return data
|
||||||
|
.fetchClassClearanceProgress(
|
||||||
|
offset: data.classClearanceProgress.length,
|
||||||
|
append: true,
|
||||||
|
)
|
||||||
|
.whenComplete(() {
|
||||||
|
if (mounted) setState(() => _loadingClassProgress = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreLocoProgress() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
setState(() => _loadingLocoProgress = true);
|
||||||
|
return data
|
||||||
|
.fetchLocoClearanceProgress(
|
||||||
|
offset: data.locoClearanceProgress.length,
|
||||||
|
append: true,
|
||||||
|
)
|
||||||
|
.whenComplete(() {
|
||||||
|
if (mounted) setState(() => _loadingLocoProgress = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreAwards() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value();
|
||||||
|
setState(() => _loadingAwards = true);
|
||||||
|
return data
|
||||||
|
.fetchBadgeAwards(
|
||||||
|
offset: data.badgeAwards.length,
|
||||||
|
append: true,
|
||||||
|
badgeCode: 'class_clearance',
|
||||||
|
limit: 20,
|
||||||
|
)
|
||||||
|
.whenComplete(() {
|
||||||
|
if (mounted) setState(() => _loadingAwards = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAwardDate(DateTime date) {
|
||||||
|
final y = date.year.toString().padLeft(4, '0');
|
||||||
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
|
final d = date.day.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _buildTierIcon(String tier, {double size = 24}) {
|
||||||
|
final lower = tier.toLowerCase();
|
||||||
|
Color? color;
|
||||||
|
switch (lower) {
|
||||||
|
case 'bronze':
|
||||||
|
color = const Color(0xFFCD7F32);
|
||||||
|
break;
|
||||||
|
case 'silver':
|
||||||
|
color = const Color(0xFFC0C0C0);
|
||||||
|
break;
|
||||||
|
case 'gold':
|
||||||
|
color = const Color(0xFFFFD700);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (color == null) return null;
|
||||||
|
return Icon(Icons.emoji_events, color: color, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _scopeToShow(BadgeAward award) {
|
||||||
|
final scope = award.scopeValue?.trim() ?? '';
|
||||||
|
if (scope.isEmpty) return null;
|
||||||
|
final code = award.badgeCode.toLowerCase();
|
||||||
|
if (code == 'loco_clearance') {
|
||||||
|
// Hide numeric loco IDs; loco details are shown separately.
|
||||||
|
if (int.tryParse(scope) != null) return null;
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ 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:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
|
import 'package:mileograph_flutter/services/api_service.dart';
|
||||||
import 'package:mileograph_flutter/services/endpoint_service.dart';
|
import 'package:mileograph_flutter/services/endpoint_service.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';
|
||||||
@@ -17,16 +19,27 @@ class SettingsPage extends StatefulWidget {
|
|||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
late final TextEditingController _endpointController;
|
late final TextEditingController _endpointController;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
bool _changingPassword = false;
|
||||||
|
final _passwordFormKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _currentPasswordController;
|
||||||
|
late final TextEditingController _newPasswordController;
|
||||||
|
late final TextEditingController _confirmPasswordController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final endpoint = context.read<EndpointService>().baseUrl;
|
final endpoint = context.read<EndpointService>().baseUrl;
|
||||||
_endpointController = TextEditingController(text: endpoint);
|
_endpointController = TextEditingController(text: endpoint);
|
||||||
|
_currentPasswordController = TextEditingController();
|
||||||
|
_newPasswordController = TextEditingController();
|
||||||
|
_confirmPasswordController = TextEditingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_currentPasswordController.dispose();
|
||||||
|
_newPasswordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
_endpointController.dispose();
|
_endpointController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -125,9 +138,46 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _changePassword() async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final formState = _passwordFormKey.currentState;
|
||||||
|
if (formState == null || !formState.validate()) return;
|
||||||
|
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
setState(() => _changingPassword = true);
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiService>();
|
||||||
|
await api.post('/user/password/change', {
|
||||||
|
'old_password': _currentPasswordController.text,
|
||||||
|
'new_password': _newPasswordController.text,
|
||||||
|
});
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Password updated successfully.')),
|
||||||
|
);
|
||||||
|
formState.reset();
|
||||||
|
_currentPasswordController.clear();
|
||||||
|
_newPasswordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to change password: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _changingPassword = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final endpointService = context.watch<EndpointService>();
|
final endpointService = context.watch<EndpointService>();
|
||||||
|
final loggedIn = context.select<AuthService, bool>(
|
||||||
|
(auth) => auth.isLoggedIn,
|
||||||
|
);
|
||||||
if (!endpointService.isLoaded) {
|
if (!endpointService.isLoaded) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
@@ -149,7 +199,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -205,6 +255,99 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
'Current: ${endpointService.baseUrl}',
|
'Current: ${endpointService.baseUrl}',
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
|
if (loggedIn) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text(
|
||||||
|
'Account',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Change your password for this account.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Form(
|
||||||
|
key: _passwordFormKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _currentPasswordController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Current password',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your current password.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _newPasswordController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'New password',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter a new password.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Confirm new password',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please confirm the new password.';
|
||||||
|
}
|
||||||
|
if (value != _newPasswordController.text) {
|
||||||
|
return 'New passwords do not match.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _changingPassword ? null : _changePassword,
|
||||||
|
icon: _changingPassword
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.lock_reset),
|
||||||
|
label: Text(
|
||||||
|
_changingPassword ? 'Updating...' : 'Change password',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
216
lib/components/pages/stats.dart
Normal file
216
lib/components/pages/stats.dart
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
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:provider/provider.dart';
|
||||||
|
|
||||||
|
class StatsPage extends StatefulWidget {
|
||||||
|
const StatsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatsPage> createState() => _StatsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsPageState extends State<StatsPage> {
|
||||||
|
final NumberFormat _mileageFormat = NumberFormat('#,##0.##');
|
||||||
|
final NumberFormat _countFormat = NumberFormat.decimalPattern();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStats({bool force = false}) {
|
||||||
|
return context.read<DataService>().fetchAboutStats(force: force);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.watch<DataService>();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Stats')),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () => _loadStats(force: true),
|
||||||
|
child: _buildContent(data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(DataService data) {
|
||||||
|
final stats = data.aboutStats;
|
||||||
|
final loading = data.isAboutStatsLoading;
|
||||||
|
|
||||||
|
if (loading && stats == null) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 140),
|
||||||
|
Center(child: CircularProgressIndicator()),
|
||||||
|
SizedBox(height: 140),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats == null || stats.sortedYears.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const Center(child: Text('No stats available yet.')),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => _loadStats(force: true),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final years = stats.sortedYears;
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: years.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12),
|
||||||
|
child: _buildYearCard(context, years[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildYearCard(BuildContext context, StatsYear year) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
year.year.toString(),
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
children: [
|
||||||
|
_buildInfoChip(
|
||||||
|
context,
|
||||||
|
label: 'Mileage',
|
||||||
|
value: '${_mileageFormat.format(year.mileage)} mi',
|
||||||
|
),
|
||||||
|
_buildInfoChip(
|
||||||
|
context,
|
||||||
|
label: 'Winners',
|
||||||
|
value: _countFormat.format(year.winnerCount),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSection<StatsClassMileage>(
|
||||||
|
context,
|
||||||
|
title: 'Top classes',
|
||||||
|
items: year.topClasses,
|
||||||
|
emptyLabel: 'No class data',
|
||||||
|
itemBuilder: (item, index) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(item.locoClass),
|
||||||
|
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildSection<StatsNetworkMileage>(
|
||||||
|
context,
|
||||||
|
title: 'Top networks',
|
||||||
|
items: year.topNetworks,
|
||||||
|
emptyLabel: 'No network data',
|
||||||
|
itemBuilder: (item, index) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(item.network),
|
||||||
|
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildSection<StatsStationVisits>(
|
||||||
|
context,
|
||||||
|
title: 'Top stations',
|
||||||
|
items: year.topStations,
|
||||||
|
emptyLabel: 'No station data',
|
||||||
|
itemBuilder: (item, index) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(item.station),
|
||||||
|
trailing: Text(
|
||||||
|
'${_countFormat.format(item.visits)} visit${item.visits == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoChip(BuildContext context,
|
||||||
|
{required String label, required String value}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Chip(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
label: Text(
|
||||||
|
'$label: $value',
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection<T>(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required List<T> items,
|
||||||
|
required Widget Function(T item, int index) itemBuilder,
|
||||||
|
String emptyLabel = 'No data',
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: ExpansionTile(
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
childrenPadding:
|
||||||
|
const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
children: items.isEmpty
|
||||||
|
? [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
emptyLabel,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: items
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((entry) => itemBuilder(entry.value, entry.key))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -12,6 +12,7 @@ class TripsPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _TripsPageState extends State<TripsPage> {
|
class _TripsPageState extends State<TripsPage> {
|
||||||
bool _initialised = false;
|
bool _initialised = false;
|
||||||
|
final Map<int, Future<List<TripLocoStat>>> _tripLocoStatsFutures = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -23,7 +24,9 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshTrips() async {
|
Future<void> _refreshTrips() async {
|
||||||
await context.read<DataService>().fetchTripDetails();
|
_tripLocoStatsFutures.clear();
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
await data.fetchTripDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
||||||
@@ -35,10 +38,7 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
"trip_id": trip.id,
|
"trip_id": trip.id,
|
||||||
"trip_name": newName,
|
"trip_name": newName,
|
||||||
});
|
});
|
||||||
await Future.wait([
|
await data.fetchTripDetails();
|
||||||
data.fetchTripDetails(),
|
|
||||||
data.fetchTrips(),
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Failed to rename trip: $e')),
|
SnackBar(content: Text('Failed to rename trip: $e')),
|
||||||
@@ -47,6 +47,27 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<TripLocoStat> _cachedTripStats(
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
|
if (trip.locoStats.isNotEmpty) return trip.locoStats;
|
||||||
|
if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TripLocoStat>> _loadTripStats(
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
|
final cached = _cachedTripStats(trip, summary);
|
||||||
|
if (cached.isNotEmpty) return Future.value(cached);
|
||||||
|
return _tripLocoStatsFutures.putIfAbsent(
|
||||||
|
trip.id,
|
||||||
|
() => context.read<DataService>().fetchTripLocoStats(trip.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _promptTripName(BuildContext context, String initial) async {
|
Future<String?> _promptTripName(BuildContext context, String initial) async {
|
||||||
final controller = TextEditingController(text: initial);
|
final controller = TextEditingController(text: initial);
|
||||||
final newName = await showDialog<String>(
|
final newName = await showDialog<String>(
|
||||||
@@ -79,8 +100,10 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final tripDetails = data.tripDetails;
|
final tripDetails = data.tripDetails;
|
||||||
final tripSummaries = data.trips;
|
final tripSummaries = data.tripList;
|
||||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
final summaryById = {
|
||||||
|
for (final summary in tripSummaries) summary.tripId: summary,
|
||||||
|
};
|
||||||
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
|
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -171,92 +194,173 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final trip = tripDetails[index - 1];
|
final trip = tripDetails[index - 1];
|
||||||
return _buildTripCard(context, trip, isMobile);
|
final summary = summaryById[trip.id];
|
||||||
|
return _buildTripCard(context, trip, summary);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) {
|
Widget _buildTripCard(
|
||||||
|
BuildContext context,
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
final legs = trip.legs;
|
final legs = trip.legs;
|
||||||
|
final legCount =
|
||||||
|
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
|
||||||
|
final dateRange = _formatDateRange(legs);
|
||||||
|
final endpoints = _formatEndpoints(legs);
|
||||||
|
final stats = _cachedTripStats(trip, summary);
|
||||||
|
final winnerCount = stats.where((e) => e.won).length;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Trip',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
trip.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
trip.name,
|
trip.mileage.toStringAsFixed(1),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style:
|
||||||
fontWeight: FontWeight.w700,
|
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs',
|
'miles',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
),
|
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||||
],
|
),
|
||||||
),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.train),
|
|
||||||
tooltip: 'Traction',
|
|
||||||
onPressed: () => _showTripWinners(context, trip),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.open_in_new),
|
|
||||||
tooltip: 'Details',
|
|
||||||
onPressed: () => _showTripDetail(context, trip),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
if (legs.isNotEmpty)
|
Wrap(
|
||||||
Column(
|
spacing: 8,
|
||||||
children: legs.take(isMobile ? 2 : 3).map((leg) {
|
runSpacing: 8,
|
||||||
return ListTile(
|
children: [
|
||||||
dense: isMobile,
|
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
|
||||||
contentPadding: EdgeInsets.zero,
|
if (dateRange != null)
|
||||||
leading: const Icon(Icons.train),
|
_buildMetaChip(context, Icons.calendar_month, dateRange),
|
||||||
title: Text('${leg.start} → ${leg.end}'),
|
if (endpoints != null)
|
||||||
subtitle: Text(
|
_buildMetaChip(context, Icons.route, endpoints),
|
||||||
_formatDate(leg.beginTime),
|
if (stats.isNotEmpty) ...[
|
||||||
maxLines: 1,
|
_buildMetaChip(context, Icons.train, '${stats.length} had'),
|
||||||
overflow: TextOverflow.ellipsis,
|
_buildMetaChip(
|
||||||
),
|
context,
|
||||||
trailing: Text(
|
Icons.emoji_events_outlined,
|
||||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
'$winnerCount winners',
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
),
|
||||||
fontWeight: FontWeight.bold,
|
] else
|
||||||
),
|
_buildMetaChip(context, Icons.train, 'No traction yet'),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
}).toList(),
|
const SizedBox(height: 12),
|
||||||
),
|
Align(
|
||||||
if (legs.length > 3)
|
alignment: Alignment.centerRight,
|
||||||
Padding(
|
child: Wrap(
|
||||||
padding: const EdgeInsets.only(top: 6.0),
|
spacing: 8,
|
||||||
child: Text(
|
runSpacing: 8,
|
||||||
'+${legs.length - 3} more legs',
|
alignment: WrapAlignment.end,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
children: [
|
||||||
),
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.train),
|
||||||
|
label: const Text('Locos'),
|
||||||
|
onPressed: () => _showTripWinners(context, trip, summary),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
label: const Text('Details'),
|
||||||
|
onPressed: () => _showTripDetail(context, trip),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMetaChip(BuildContext context, IconData icon, String label) {
|
||||||
|
return Chip(
|
||||||
|
avatar: Icon(icon, size: 16),
|
||||||
|
label: Text(label),
|
||||||
|
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _formatDateRange(List<TripLeg> legs) {
|
||||||
|
final beginTimes =
|
||||||
|
legs.map((e) => e.beginTime).whereType<DateTime>().toList();
|
||||||
|
if (beginTimes.isEmpty) return null;
|
||||||
|
final start = beginTimes.first;
|
||||||
|
final end = beginTimes.last;
|
||||||
|
final startStr = _formatFriendlyDate(start);
|
||||||
|
final endStr = _formatFriendlyDate(end);
|
||||||
|
if (startStr == endStr) return startStr;
|
||||||
|
return '$startStr - $endStr';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFriendlyDate(DateTime date) {
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
final day = date.day.toString().padLeft(2, '0');
|
||||||
|
final monthIndex = (date.month - 1).clamp(0, months.length - 1).toInt();
|
||||||
|
final month = months[monthIndex];
|
||||||
|
return '$day $month ${date.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _formatEndpoints(List<TripLeg> legs) {
|
||||||
|
if (legs.isEmpty) return null;
|
||||||
|
final start = legs.first.start;
|
||||||
|
final end = legs.last.end;
|
||||||
|
if (start.isEmpty && end.isEmpty) return null;
|
||||||
|
final startLabel = start.isNotEmpty ? start : '—';
|
||||||
|
final endLabel = end.isNotEmpty ? end : '—';
|
||||||
|
return '$startLabel → $endLabel';
|
||||||
|
}
|
||||||
|
|
||||||
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')}';
|
||||||
@@ -323,6 +427,7 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
data.fetchTripDetails(),
|
data.fetchTripDetails(),
|
||||||
data.fetchTrips(),
|
data.fetchTrips(),
|
||||||
]);
|
]);
|
||||||
|
_tripLocoStatsFutures.remove(trip.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Deleted "${trip.name}"')),
|
SnackBar(content: Text('Deleted "${trip.name}"')),
|
||||||
@@ -419,15 +524,19 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTripWinners(BuildContext context, TripDetail trip) {
|
void _showTripWinners(
|
||||||
|
BuildContext context,
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
final data = context.read<DataService>();
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: FutureBuilder<List<TripLocoStat>>(
|
child: FutureBuilder<List<TripLocoStat>>(
|
||||||
future: data.fetchTripLocoStats(trip.id),
|
future: _loadTripStats(trip, summary),
|
||||||
|
initialData: _cachedTripStats(trip, summary),
|
||||||
builder: (ctx, snapshot) {
|
builder: (ctx, snapshot) {
|
||||||
final items = snapshot.data ?? [];
|
final items = snapshot.data ?? [];
|
||||||
final loading =
|
final loading =
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
@@ -20,6 +22,35 @@ String _asString(dynamic value, [String fallback = '']) {
|
|||||||
return (str == null) ? fallback : str;
|
return (str == null) ? fallback : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _asStringList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
final trimmed = value?.toString().trim() ?? '';
|
||||||
|
if (trimmed.isEmpty) return const [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(trimmed);
|
||||||
|
if (decoded is List) {
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (trimmed.contains('->')) {
|
||||||
|
return trimmed
|
||||||
|
.split('->')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (trimmed.contains(',')) {
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [trimmed];
|
||||||
|
}
|
||||||
|
|
||||||
bool _asBool(dynamic value, [bool fallback = false]) {
|
bool _asBool(dynamic value, [bool fallback = false]) {
|
||||||
if (value is bool) return value;
|
if (value is bool) return value;
|
||||||
if (value is num) return value != 0;
|
if (value is num) return value != 0;
|
||||||
@@ -137,6 +168,208 @@ class YearlyMileage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StatsAbout {
|
||||||
|
final Map<int, StatsYear> years;
|
||||||
|
|
||||||
|
StatsAbout({required this.years});
|
||||||
|
|
||||||
|
factory StatsAbout.fromJson(Map<String, dynamic> json) {
|
||||||
|
final mileageByYear = <int, double>{};
|
||||||
|
final classByYear = <int, List<StatsClassMileage>>{};
|
||||||
|
final networkByYear = <int, List<StatsNetworkMileage>>{};
|
||||||
|
final stationByYear = <int, List<StatsStationVisits>>{};
|
||||||
|
final winnersByYear = <int, int>{};
|
||||||
|
|
||||||
|
void addYearMileage(dynamic entry) {
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
final year = entry['year'] is int
|
||||||
|
? entry['year'] as int
|
||||||
|
: int.tryParse('${entry['year']}');
|
||||||
|
if (year != null) {
|
||||||
|
mileageByYear[year] = _asDouble(entry['mileage']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json['year_mileages'] is List) {
|
||||||
|
for (final entry in json['year_mileages']) {
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
addYearMileage(entry);
|
||||||
|
} else if (entry is Map) {
|
||||||
|
addYearMileage(entry
|
||||||
|
.map((key, value) => MapEntry(key.toString(), value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsClassMileage> parseClassList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => StatsClassMileage.fromJson(
|
||||||
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsNetworkMileage> parseNetworkList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => StatsNetworkMileage.fromJson(
|
||||||
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsStationVisits> parseStationList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => StatsStationVisits.fromJson(
|
||||||
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
void parseYearMap<T>(
|
||||||
|
dynamic source,
|
||||||
|
Map<int, T> target,
|
||||||
|
T Function(dynamic value) mapper,
|
||||||
|
) {
|
||||||
|
if (source is Map) {
|
||||||
|
source.forEach((key, value) {
|
||||||
|
final year = int.tryParse(key.toString());
|
||||||
|
if (year == null) return;
|
||||||
|
target[year] = mapper(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseYearMap<List<StatsClassMileage>>(
|
||||||
|
json['top_classes'],
|
||||||
|
classByYear,
|
||||||
|
parseClassList,
|
||||||
|
);
|
||||||
|
parseYearMap<List<StatsNetworkMileage>>(
|
||||||
|
json['top_networks'],
|
||||||
|
networkByYear,
|
||||||
|
parseNetworkList,
|
||||||
|
);
|
||||||
|
parseYearMap<List<StatsStationVisits>>(
|
||||||
|
json['top_stations'],
|
||||||
|
stationByYear,
|
||||||
|
parseStationList,
|
||||||
|
);
|
||||||
|
if (json['year_winners'] is Map) {
|
||||||
|
(json['year_winners'] as Map).forEach((key, value) {
|
||||||
|
final year = int.tryParse(key.toString());
|
||||||
|
if (year == null) return;
|
||||||
|
if (value is List) {
|
||||||
|
winnersByYear[year] = value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final years = <int>{
|
||||||
|
...mileageByYear.keys,
|
||||||
|
...classByYear.keys,
|
||||||
|
...networkByYear.keys,
|
||||||
|
...stationByYear.keys,
|
||||||
|
...winnersByYear.keys,
|
||||||
|
}..removeWhere((year) => year == 0);
|
||||||
|
|
||||||
|
final yearMap = <int, StatsYear>{};
|
||||||
|
for (final year in years) {
|
||||||
|
yearMap[year] = StatsYear(
|
||||||
|
year: year,
|
||||||
|
mileage: mileageByYear[year] ?? 0,
|
||||||
|
topClasses: classByYear[year] ?? const [],
|
||||||
|
topNetworks: networkByYear[year] ?? const [],
|
||||||
|
topStations: stationByYear[year] ?? const [],
|
||||||
|
winnerCount: winnersByYear[year] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatsAbout(years: yearMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsYear> get sortedYears {
|
||||||
|
final list = years.values.toList();
|
||||||
|
list.sort((a, b) => b.year.compareTo(a.year));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsYear {
|
||||||
|
final int year;
|
||||||
|
final double mileage;
|
||||||
|
final List<StatsClassMileage> topClasses;
|
||||||
|
final List<StatsNetworkMileage> topNetworks;
|
||||||
|
final List<StatsStationVisits> topStations;
|
||||||
|
final int winnerCount;
|
||||||
|
|
||||||
|
StatsYear({
|
||||||
|
required this.year,
|
||||||
|
required this.mileage,
|
||||||
|
required this.topClasses,
|
||||||
|
required this.topNetworks,
|
||||||
|
required this.topStations,
|
||||||
|
required this.winnerCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsClassMileage {
|
||||||
|
final String locoClass;
|
||||||
|
final double mileage;
|
||||||
|
|
||||||
|
StatsClassMileage({
|
||||||
|
required this.locoClass,
|
||||||
|
required this.mileage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatsClassMileage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
StatsClassMileage(
|
||||||
|
locoClass: _asString(json['loco_class'], 'Unknown'),
|
||||||
|
mileage: _asDouble(json['mileage']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsNetworkMileage {
|
||||||
|
final String network;
|
||||||
|
final double mileage;
|
||||||
|
|
||||||
|
StatsNetworkMileage({
|
||||||
|
required this.network,
|
||||||
|
required this.mileage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatsNetworkMileage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
StatsNetworkMileage(
|
||||||
|
network: _asString(json['network'], 'Unknown'),
|
||||||
|
mileage: _asDouble(json['mileage']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsStationVisits {
|
||||||
|
final String station;
|
||||||
|
final int visits;
|
||||||
|
|
||||||
|
StatsStationVisits({
|
||||||
|
required this.station,
|
||||||
|
required this.visits,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatsStationVisits.fromJson(Map<String, dynamic> json) =>
|
||||||
|
StatsStationVisits(
|
||||||
|
station: _asString(json['station'], 'Unknown'),
|
||||||
|
visits: _asInt(json['visits']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class Loco {
|
class Loco {
|
||||||
final int id;
|
final int id;
|
||||||
final String type, number, locoClass;
|
final String type, number, locoClass;
|
||||||
@@ -487,25 +720,45 @@ class TripSummary {
|
|||||||
final int tripId;
|
final int tripId;
|
||||||
final String tripName;
|
final String tripName;
|
||||||
final double tripMileage;
|
final double tripMileage;
|
||||||
|
final int legCount;
|
||||||
|
final List<TripLocoStat> locoStats;
|
||||||
|
|
||||||
|
int get locoHadCount => locoStats.length;
|
||||||
|
int get winnersCount => locoStats.where((e) => e.won).length;
|
||||||
|
|
||||||
TripSummary({
|
TripSummary({
|
||||||
required this.tripId,
|
required this.tripId,
|
||||||
required this.tripName,
|
required this.tripName,
|
||||||
required this.tripMileage,
|
required this.tripMileage,
|
||||||
});
|
this.legCount = 0,
|
||||||
|
List<TripLocoStat>? locoStats,
|
||||||
|
}) : locoStats = locoStats ?? const [];
|
||||||
|
|
||||||
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
|
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
|
||||||
tripId: _asInt(json['trip_id']),
|
tripId: _asInt(json['trip_id']),
|
||||||
tripName: _asString(json['trip_name']),
|
tripName: _asString(json['trip_name']),
|
||||||
tripMileage: _asDouble(json['trip_mileage']),
|
tripMileage: _asDouble(json['trip_mileage']),
|
||||||
|
legCount: _asInt(
|
||||||
|
json['leg_count'],
|
||||||
|
(json['trip_legs'] as List?)?.length ?? 0,
|
||||||
|
),
|
||||||
|
locoStats: TripLocoStat.listFromJson(
|
||||||
|
json['stats'] ?? json['trip_locos'] ?? json['locos'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class Leg {
|
class Leg {
|
||||||
final int id, tripId, timezone, driving;
|
final int id, tripId, timezone, driving;
|
||||||
final String start, end, route, network, notes, headcode, user;
|
final String start, end, network, notes, headcode, user;
|
||||||
|
final String origin, destination;
|
||||||
|
final List<String> route;
|
||||||
final DateTime beginTime;
|
final DateTime beginTime;
|
||||||
|
final DateTime? endTime;
|
||||||
|
final DateTime? originTime;
|
||||||
|
final DateTime? destinationTime;
|
||||||
final double mileage;
|
final double mileage;
|
||||||
|
final int? beginDelayMinutes, endDelayMinutes;
|
||||||
final List<Loco> locos;
|
final List<Loco> locos;
|
||||||
|
|
||||||
Leg({
|
Leg({
|
||||||
@@ -523,27 +776,55 @@ class Leg {
|
|||||||
required this.driving,
|
required this.driving,
|
||||||
required this.user,
|
required this.user,
|
||||||
required this.locos,
|
required this.locos,
|
||||||
|
this.endTime,
|
||||||
|
this.originTime,
|
||||||
|
this.destinationTime,
|
||||||
|
this.beginDelayMinutes,
|
||||||
|
this.endDelayMinutes,
|
||||||
|
this.origin = '',
|
||||||
|
this.destination = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Leg.fromJson(Map<String, dynamic> json) => Leg(
|
factory Leg.fromJson(Map<String, dynamic> json) {
|
||||||
id: _asInt(json['leg_id']),
|
final endTimeRaw = json['leg_end_time'];
|
||||||
tripId: _asInt(json['leg_trip']),
|
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
|
||||||
start: _asString(json['leg_start']),
|
? null
|
||||||
end: _asString(json['leg_end']),
|
: _asDateTime(endTimeRaw);
|
||||||
beginTime: _asDateTime(json['leg_begin_time']),
|
return Leg(
|
||||||
timezone: _asInt(json['leg_timezone']),
|
id: _asInt(json['leg_id']),
|
||||||
network: _asString(json['leg_network']),
|
tripId: _asInt(json['leg_trip']),
|
||||||
route: _asString(json['leg_route']),
|
start: _asString(json['leg_start']),
|
||||||
mileage: _asDouble(json['leg_mileage']),
|
end: _asString(json['leg_end']),
|
||||||
notes: _asString(json['leg_notes']),
|
beginTime: _asDateTime(json['leg_begin_time']),
|
||||||
headcode: _asString(json['leg_headcode']),
|
endTime: parsedEndTime,
|
||||||
driving: _asInt(json['leg_driving']),
|
originTime: json['leg_origin_time'] == null
|
||||||
user: _asString(json['leg_user']),
|
? null
|
||||||
locos: (json['locos'] is List ? (json['locos'] as List) : const [])
|
: _asDateTime(json['leg_origin_time']),
|
||||||
.whereType<Map>()
|
destinationTime: json['leg_destination_time'] == null
|
||||||
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
|
? null
|
||||||
.toList(),
|
: _asDateTime(json['leg_destination_time']),
|
||||||
);
|
timezone: _asInt(json['leg_timezone']),
|
||||||
|
network: _asString(json['leg_network']),
|
||||||
|
route: _asStringList(json['leg_route']),
|
||||||
|
mileage: _asDouble(json['leg_mileage']),
|
||||||
|
notes: _asString(json['leg_notes']),
|
||||||
|
headcode: _asString(json['leg_headcode']),
|
||||||
|
driving: _asInt(json['leg_driving']),
|
||||||
|
user: _asString(json['leg_user']),
|
||||||
|
locos: (json['locos'] is List ? (json['locos'] as List) : const [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList(),
|
||||||
|
beginDelayMinutes: json['leg_begin_delay'] == null
|
||||||
|
? null
|
||||||
|
: _asInt(json['leg_begin_delay']),
|
||||||
|
endDelayMinutes: json['leg_end_delay'] == null
|
||||||
|
? null
|
||||||
|
: _asInt(json['leg_end_delay']),
|
||||||
|
origin: _asString(json['leg_origin']),
|
||||||
|
destination: _asString(json['leg_destination']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RouteError {
|
class RouteError {
|
||||||
@@ -625,17 +906,23 @@ class TripLeg {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
|
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
|
||||||
id: json['leg_id'],
|
id: _asInt(json['leg_id']),
|
||||||
start: json['leg_start'] ?? '',
|
start: _asString(json['leg_start']),
|
||||||
end: json['leg_end'] ?? '',
|
end: _asString(json['leg_end']),
|
||||||
beginTime:
|
beginTime:
|
||||||
json['leg_begin_time'] != null && json['leg_begin_time'] is String
|
json['leg_begin_time'] != null && json['leg_begin_time'] is String
|
||||||
? DateTime.tryParse(json['leg_begin_time'])
|
? DateTime.tryParse(json['leg_begin_time'])
|
||||||
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
|
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
|
||||||
network: json['leg_network'],
|
network: _asString(json['leg_network'], ''),
|
||||||
route: json['leg_route'],
|
route: () {
|
||||||
|
final route = json['leg_route'];
|
||||||
|
if (route is List) {
|
||||||
|
return route.whereType<String>().join(' → ');
|
||||||
|
}
|
||||||
|
return _asString(route, '');
|
||||||
|
}(),
|
||||||
mileage: (json['leg_mileage'] as num?)?.toDouble(),
|
mileage: (json['leg_mileage'] as num?)?.toDouble(),
|
||||||
notes: json['leg_notes'],
|
notes: _asString(json['leg_notes'], ''),
|
||||||
locos:
|
locos:
|
||||||
(json['locos'] as List?)
|
(json['locos'] as List?)
|
||||||
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
|
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
|
||||||
@@ -649,21 +936,32 @@ class TripDetail {
|
|||||||
final String name;
|
final String name;
|
||||||
final double mileage;
|
final double mileage;
|
||||||
final int legCount;
|
final int legCount;
|
||||||
|
final List<TripLocoStat> locoStats;
|
||||||
final List<TripLeg> legs;
|
final List<TripLeg> legs;
|
||||||
|
|
||||||
|
int get locoHadCount => locoStats.length;
|
||||||
|
int get winnersCount => locoStats.where((e) => e.won).length;
|
||||||
|
|
||||||
TripDetail({
|
TripDetail({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.mileage,
|
required this.mileage,
|
||||||
required this.legCount,
|
required this.legCount,
|
||||||
required this.legs,
|
required this.legs,
|
||||||
});
|
List<TripLocoStat>? locoStats,
|
||||||
|
}) : locoStats = locoStats ?? const [];
|
||||||
|
|
||||||
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
|
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
|
||||||
id: json['trip_id'] ?? json['id'] ?? 0,
|
id: json['trip_id'] ?? json['id'] ?? 0,
|
||||||
name: json['trip_name'] ?? '',
|
name: json['trip_name'] ?? '',
|
||||||
mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0,
|
mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0,
|
||||||
legCount: json['leg_count'] ?? ((json['trip_legs'] as List?)?.length ?? 0),
|
legCount: _asInt(
|
||||||
|
json['leg_count'],
|
||||||
|
(json['trip_legs'] as List?)?.length ?? 0,
|
||||||
|
),
|
||||||
|
locoStats: TripLocoStat.listFromJson(
|
||||||
|
json['stats'] ?? json['trip_locos'] ?? json['locos'],
|
||||||
|
),
|
||||||
legs:
|
legs:
|
||||||
(json['trip_legs'] as List?)
|
(json['trip_legs'] as List?)
|
||||||
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
|
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
|
||||||
@@ -712,6 +1010,26 @@ class TripLocoStat {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<TripLocoStat> listFromJson(dynamic json) {
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json.expand((e) => e is List ? e : [e]).toList();
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
|
||||||
|
final candidate = json[key];
|
||||||
|
if (candidate is List) {
|
||||||
|
list = candidate.expand((e) => e is List ? e : [e]).toList();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list == null) return const [];
|
||||||
|
return list
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => TripLocoStat.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
static bool _parseWonFlag(dynamic value) {
|
static bool _parseWonFlag(dynamic value) {
|
||||||
if (value == null) return false;
|
if (value == null) return false;
|
||||||
if (value is bool) return value;
|
if (value is bool) return value;
|
||||||
@@ -750,3 +1068,162 @@ class EventField {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UserNotification {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final bool dismissed;
|
||||||
|
|
||||||
|
UserNotification({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.dismissed,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserNotification.fromJson(Map<String, dynamic> json) {
|
||||||
|
final created = json['created_at'] ?? json['createdAt'];
|
||||||
|
DateTime? createdAt;
|
||||||
|
if (created is String) {
|
||||||
|
createdAt = DateTime.tryParse(created);
|
||||||
|
} else if (created is DateTime) {
|
||||||
|
createdAt = created;
|
||||||
|
}
|
||||||
|
return UserNotification(
|
||||||
|
id: _asInt(json['notification_id'] ?? json['id']),
|
||||||
|
title: _asString(json['title']),
|
||||||
|
body: _asString(json['body']),
|
||||||
|
createdAt: createdAt,
|
||||||
|
dismissed: _asBool(json['dismissed'] ?? false, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BadgeAward {
|
||||||
|
final int id;
|
||||||
|
final int badgeId;
|
||||||
|
final String badgeCode;
|
||||||
|
final String badgeTier;
|
||||||
|
final String? scopeValue;
|
||||||
|
final DateTime? awardedAt;
|
||||||
|
final LocoSummary? loco;
|
||||||
|
|
||||||
|
BadgeAward({
|
||||||
|
required this.id,
|
||||||
|
required this.badgeId,
|
||||||
|
required this.badgeCode,
|
||||||
|
required this.badgeTier,
|
||||||
|
this.scopeValue,
|
||||||
|
this.awardedAt,
|
||||||
|
this.loco,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BadgeAward.fromJson(Map<String, dynamic> json) {
|
||||||
|
final awarded = json['awarded_at'] ?? json['awardedAt'];
|
||||||
|
DateTime? awardedAt;
|
||||||
|
if (awarded is String) {
|
||||||
|
awardedAt = DateTime.tryParse(awarded);
|
||||||
|
} else if (awarded is DateTime) {
|
||||||
|
awardedAt = awarded;
|
||||||
|
}
|
||||||
|
final locoJson = json['loco'];
|
||||||
|
LocoSummary? loco;
|
||||||
|
if (locoJson is Map<String, dynamic>) {
|
||||||
|
loco = LocoSummary.fromJson(Map<String, dynamic>.from(locoJson));
|
||||||
|
}
|
||||||
|
return BadgeAward(
|
||||||
|
id: _asInt(json['award_id'] ?? json['id']),
|
||||||
|
badgeId: _asInt(json['badge_id'] ?? 0),
|
||||||
|
badgeCode: _asString(json['badge_code']),
|
||||||
|
badgeTier: _asString(json['badge_tier']),
|
||||||
|
scopeValue: _asString(json['scope_value']),
|
||||||
|
awardedAt: awardedAt,
|
||||||
|
loco: loco,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClassClearanceProgress {
|
||||||
|
final String className;
|
||||||
|
final int completed;
|
||||||
|
final int total;
|
||||||
|
final double percentComplete;
|
||||||
|
|
||||||
|
ClassClearanceProgress({
|
||||||
|
required this.className,
|
||||||
|
required this.completed,
|
||||||
|
required this.total,
|
||||||
|
required this.percentComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
|
||||||
|
final name = _asString(json['class'] ?? json['class_name'] ?? json['name']);
|
||||||
|
final completed = _asInt(
|
||||||
|
json['completed'] ?? json['done'] ?? json['count'] ?? json['had'],
|
||||||
|
);
|
||||||
|
final total = _asInt(json['total'] ?? json['required'] ?? json['goal']);
|
||||||
|
double percent = _asDouble(
|
||||||
|
json['percent_complete'] ??
|
||||||
|
json['percent'] ??
|
||||||
|
json['completion'] ??
|
||||||
|
json['pct'],
|
||||||
|
);
|
||||||
|
if (percent == 0 && total > 0) {
|
||||||
|
percent = (completed / total) * 100;
|
||||||
|
}
|
||||||
|
return ClassClearanceProgress(
|
||||||
|
className: name.isNotEmpty ? name : 'Class',
|
||||||
|
completed: completed,
|
||||||
|
total: total,
|
||||||
|
percentComplete: percent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocoClearanceProgress {
|
||||||
|
final LocoSummary loco;
|
||||||
|
final double mileage;
|
||||||
|
final double required;
|
||||||
|
final String nextTier;
|
||||||
|
final List<String> awardedTiers;
|
||||||
|
final double percent;
|
||||||
|
|
||||||
|
LocoClearanceProgress({
|
||||||
|
required this.loco,
|
||||||
|
required this.mileage,
|
||||||
|
required this.required,
|
||||||
|
required this.nextTier,
|
||||||
|
required this.awardedTiers,
|
||||||
|
required this.percent,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LocoClearanceProgress.fromJson(Map<String, dynamic> json) {
|
||||||
|
final locoJson = json['loco'];
|
||||||
|
final loco = locoJson is Map<String, dynamic>
|
||||||
|
? LocoSummary.fromJson(Map<String, dynamic>.from(locoJson))
|
||||||
|
: LocoSummary(
|
||||||
|
locoId: _asInt(json['loco_id']),
|
||||||
|
locoType: _asString(json['loco_type']),
|
||||||
|
locoNumber: _asString(json['loco_number']),
|
||||||
|
locoName: _asString(json['loco_name']),
|
||||||
|
locoClass: _asString(json['loco_class']),
|
||||||
|
locoOperator: _asString(json['operator']),
|
||||||
|
powering: true,
|
||||||
|
locoNotes: null,
|
||||||
|
locoEvn: null,
|
||||||
|
);
|
||||||
|
return LocoClearanceProgress(
|
||||||
|
loco: loco,
|
||||||
|
mileage: _asDouble(json['mileage']),
|
||||||
|
required: _asDouble(json['required']),
|
||||||
|
nextTier: _asString(json['next_tier']),
|
||||||
|
awardedTiers: (json['awarded_tiers'] as List? ?? [])
|
||||||
|
.map((e) => e.toString())
|
||||||
|
.toList(),
|
||||||
|
percent: _asDouble(json['percent']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,7 +128,12 @@ class ApiService {
|
|||||||
await _onUnauthorized!();
|
await _onUnauthorized!();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw Exception('API error ${res.statusCode}: $body');
|
final message = _extractErrorMessage(body);
|
||||||
|
throw ApiException(
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
message: message,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dynamic _decodeBody(http.Response res) {
|
dynamic _decodeBody(http.Response res) {
|
||||||
@@ -149,4 +154,41 @@ class ApiService {
|
|||||||
return res.body;
|
return res.body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _extractErrorMessage(dynamic body) {
|
||||||
|
if (body == null) return 'No response body';
|
||||||
|
if (body is String) return body;
|
||||||
|
if (body is Map<String, dynamic>) {
|
||||||
|
for (final key in ['message', 'error', 'detail', 'msg']) {
|
||||||
|
final val = body[key];
|
||||||
|
if (val is String && val.trim().isNotEmpty) return val;
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
}
|
||||||
|
if (body is List) {
|
||||||
|
final parts = body
|
||||||
|
.map((e) => e is Map
|
||||||
|
? _extractErrorMessage(Map<String, dynamic>.from(e))
|
||||||
|
: e.toString())
|
||||||
|
.where((e) => e.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isNotEmpty) return parts.join('; ');
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final int statusCode;
|
||||||
|
final String message;
|
||||||
|
final dynamic body;
|
||||||
|
|
||||||
|
ApiException({
|
||||||
|
required this.statusCode,
|
||||||
|
required this.message,
|
||||||
|
this.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'API error $statusCode: $message';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ import 'package:mileograph_flutter/services/api_service.dart';
|
|||||||
part 'data_service_core.dart';
|
part 'data_service_core.dart';
|
||||||
part 'data_service_traction.dart';
|
part 'data_service_traction.dart';
|
||||||
part 'data_service_trips.dart';
|
part 'data_service_trips.dart';
|
||||||
|
part 'data_service_notifications.dart';
|
||||||
|
part 'data_service_badges.dart';
|
||||||
|
part 'data_service_stats.dart';
|
||||||
|
|||||||
130
lib/services/data_service/data_service_badges.dart
Normal file
130
lib/services/data_service/data_service_badges.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceBadges on DataService {
|
||||||
|
Future<void> fetchBadgeAwards({
|
||||||
|
int offset = 0,
|
||||||
|
int limit = 50,
|
||||||
|
bool append = false,
|
||||||
|
String badgeCode = 'class_clearance',
|
||||||
|
}) async {
|
||||||
|
_isBadgeAwardsLoading = true;
|
||||||
|
if (!append) _badgeAwards = [];
|
||||||
|
try {
|
||||||
|
final json = await api.get(
|
||||||
|
'/badge/awards/me?limit=$limit&offset=$offset&badge_code=$badgeCode',
|
||||||
|
);
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['awards', 'badge_awards', 'data']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final parsed = list
|
||||||
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map(BadgeAward.fromJson)
|
||||||
|
.toList();
|
||||||
|
final items = parsed ?? [];
|
||||||
|
_badgeAwards =
|
||||||
|
append ? [..._badgeAwards, ...items] : items;
|
||||||
|
_badgeAwards.sort((a, b) {
|
||||||
|
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
|
||||||
|
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
|
||||||
|
return bTs.compareTo(aTs);
|
||||||
|
});
|
||||||
|
_badgeAwardsHasMore = items.length >= limit;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch badge awards: $e');
|
||||||
|
if (!append) _badgeAwards = [];
|
||||||
|
_badgeAwardsHasMore = false;
|
||||||
|
} finally {
|
||||||
|
_isBadgeAwardsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchClassClearanceProgress({
|
||||||
|
int offset = 0,
|
||||||
|
int limit = 20,
|
||||||
|
bool append = false,
|
||||||
|
}) async {
|
||||||
|
_isClassClearanceProgressLoading = true;
|
||||||
|
if (!append) _classClearanceProgress = [];
|
||||||
|
try {
|
||||||
|
final json =
|
||||||
|
await api.get('/badge/completion/class?limit=$limit&offset=$offset');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['progress', 'data', 'items', 'classes']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final parsed = list
|
||||||
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map(ClassClearanceProgress.fromJson)
|
||||||
|
.toList();
|
||||||
|
final items = parsed ?? [];
|
||||||
|
_classClearanceProgress =
|
||||||
|
append ? [..._classClearanceProgress, ...items] : items;
|
||||||
|
_classClearanceHasMore = items.length >= limit;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch class clearance progress: $e');
|
||||||
|
if (!append) _classClearanceProgress = [];
|
||||||
|
_classClearanceHasMore = false;
|
||||||
|
} finally {
|
||||||
|
_isClassClearanceProgressLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchLocoClearanceProgress({
|
||||||
|
int offset = 0,
|
||||||
|
int limit = 20,
|
||||||
|
bool append = false,
|
||||||
|
}) async {
|
||||||
|
_isLocoClearanceProgressLoading = true;
|
||||||
|
if (!append) _locoClearanceProgress = [];
|
||||||
|
try {
|
||||||
|
final json =
|
||||||
|
await api.get('/badge/completion/loco?limit=$limit&offset=$offset');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['progress', 'data', 'items', 'locos']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final parsed = list
|
||||||
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map(LocoClearanceProgress.fromJson)
|
||||||
|
.toList();
|
||||||
|
final items = parsed ?? [];
|
||||||
|
_locoClearanceProgress =
|
||||||
|
append ? [..._locoClearanceProgress, ...items] : items;
|
||||||
|
_locoClearanceHasMore = items.length >= limit;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch loco clearance progress: $e');
|
||||||
|
if (!append) _locoClearanceProgress = [];
|
||||||
|
_locoClearanceHasMore = false;
|
||||||
|
} finally {
|
||||||
|
_isLocoClearanceProgressLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,17 @@ class DataService extends ChangeNotifier {
|
|||||||
|
|
||||||
DataService({required this.api});
|
DataService({required this.api});
|
||||||
|
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
|
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
|
||||||
|
|
||||||
// Homepage Data
|
// Homepage Data
|
||||||
HomepageStats? _homepageStats;
|
HomepageStats? _homepageStats;
|
||||||
HomepageStats? get homepageStats => _homepageStats;
|
HomepageStats? get homepageStats => _homepageStats;
|
||||||
|
StatsAbout? _aboutStats;
|
||||||
|
StatsAbout? get aboutStats => _aboutStats;
|
||||||
|
bool _isAboutStatsLoading = false;
|
||||||
|
bool get isAboutStatsLoading => _isAboutStatsLoading;
|
||||||
|
|
||||||
// Legs Data
|
// Legs Data
|
||||||
List<Leg> _legs = [];
|
List<Leg> _legs = [];
|
||||||
@@ -48,6 +54,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) =>
|
||||||
@@ -88,6 +97,36 @@ class DataService extends ChangeNotifier {
|
|||||||
bool _isOnThisDayLoading = false;
|
bool _isOnThisDayLoading = false;
|
||||||
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
List<UserNotification> _notifications = [];
|
||||||
|
List<UserNotification> get notifications => _notifications;
|
||||||
|
bool _isNotificationsLoading = false;
|
||||||
|
bool get isNotificationsLoading => _isNotificationsLoading;
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
List<BadgeAward> _badgeAwards = [];
|
||||||
|
List<BadgeAward> get badgeAwards => _badgeAwards;
|
||||||
|
bool _isBadgeAwardsLoading = false;
|
||||||
|
bool get isBadgeAwardsLoading => _isBadgeAwardsLoading;
|
||||||
|
bool _badgeAwardsHasMore = false;
|
||||||
|
bool get badgeAwardsHasMore => _badgeAwardsHasMore;
|
||||||
|
List<ClassClearanceProgress> _classClearanceProgress = [];
|
||||||
|
List<ClassClearanceProgress> get classClearanceProgress =>
|
||||||
|
_classClearanceProgress;
|
||||||
|
bool _isClassClearanceProgressLoading = false;
|
||||||
|
bool get isClassClearanceProgressLoading =>
|
||||||
|
_isClassClearanceProgressLoading;
|
||||||
|
bool _classClearanceHasMore = false;
|
||||||
|
bool get classClearanceHasMore => _classClearanceHasMore;
|
||||||
|
List<LocoClearanceProgress> _locoClearanceProgress = [];
|
||||||
|
List<LocoClearanceProgress> get locoClearanceProgress =>
|
||||||
|
_locoClearanceProgress;
|
||||||
|
bool _isLocoClearanceProgressLoading = false;
|
||||||
|
bool get isLocoClearanceProgressLoading =>
|
||||||
|
_isLocoClearanceProgressLoading;
|
||||||
|
bool _locoClearanceHasMore = false;
|
||||||
|
bool get locoClearanceHasMore => _locoClearanceHasMore;
|
||||||
|
|
||||||
static const List<EventField> _fallbackEventFields = [
|
static const List<EventField> _fallbackEventFields = [
|
||||||
EventField(name: 'operator', display: 'Operator'),
|
EventField(name: 'operator', display: 'Operator'),
|
||||||
EventField(name: 'status', display: 'Status'),
|
EventField(name: 'status', display: 'Status'),
|
||||||
@@ -342,6 +381,8 @@ class DataService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
|
_currentUserId = null;
|
||||||
|
_lastLegsFetch = const _LegFetchOptions();
|
||||||
_homepageStats = null;
|
_homepageStats = null;
|
||||||
_legs = [];
|
_legs = [];
|
||||||
_onThisDay = [];
|
_onThisDay = [];
|
||||||
@@ -352,9 +393,44 @@ class DataService extends ChangeNotifier {
|
|||||||
_isLocoTimelineLoading.clear();
|
_isLocoTimelineLoading.clear();
|
||||||
_latestLocoChanges = [];
|
_latestLocoChanges = [];
|
||||||
_isLatestLocoChangesLoading = false;
|
_isLatestLocoChangesLoading = false;
|
||||||
|
_isHomepageLoading = false;
|
||||||
|
_isOnThisDayLoading = false;
|
||||||
|
_legsHasMore = false;
|
||||||
|
_isLegsLoading = false;
|
||||||
|
_traction = [];
|
||||||
|
_isTractionLoading = false;
|
||||||
|
_tractionHasMore = false;
|
||||||
|
_latestLocoChangesHasMore = false;
|
||||||
|
_latestLocoChangesFetched = 0;
|
||||||
|
_isTripDetailsLoading = false;
|
||||||
|
_locoClasses = [];
|
||||||
|
_tripList = [];
|
||||||
|
_stationCache.clear();
|
||||||
|
_stationInFlightByKey.clear();
|
||||||
|
_stationNetworks = [];
|
||||||
|
_stationCountryNetworks = {};
|
||||||
|
_stationFiltersFetchedAt = null;
|
||||||
|
_notifications = [];
|
||||||
|
_isNotificationsLoading = false;
|
||||||
|
_badgeAwards = [];
|
||||||
|
_badgeAwardsHasMore = false;
|
||||||
|
_isBadgeAwardsLoading = false;
|
||||||
|
_classClearanceProgress = [];
|
||||||
|
_isClassClearanceProgressLoading = false;
|
||||||
|
_classClearanceHasMore = false;
|
||||||
|
_locoClearanceProgress = [];
|
||||||
|
_isLocoClearanceProgressLoading = false;
|
||||||
|
_locoClearanceHasMore = false;
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAuthChanged(String? userId) {
|
||||||
|
if (_currentUserId == userId) return;
|
||||||
|
_currentUserId = userId;
|
||||||
|
clear();
|
||||||
|
_currentUserId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
double getMileageForCurrentYear() {
|
double getMileageForCurrentYear() {
|
||||||
final currentYear = DateTime.now().year;
|
final currentYear = DateTime.now().year;
|
||||||
return getMileageForYear(currentYear) ?? 0;
|
return getMileageForYear(currentYear) ?? 0;
|
||||||
|
|||||||
62
lib/services/data_service/data_service_notifications.dart
Normal file
62
lib/services/data_service/data_service_notifications.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceNotifications on DataService {
|
||||||
|
Future<void> fetchNotifications() async {
|
||||||
|
_isNotificationsLoading = true;
|
||||||
|
try {
|
||||||
|
final json = await api.get('/notifications');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['notifications', 'data', 'items']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final parsed = list
|
||||||
|
?.whereType<Map<String, dynamic>>()
|
||||||
|
.map(UserNotification.fromJson)
|
||||||
|
.where((n) => !n.dismissed)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (parsed != null) {
|
||||||
|
parsed.sort((a, b) {
|
||||||
|
final aTs = a.createdAt?.millisecondsSinceEpoch ?? 0;
|
||||||
|
final bTs = b.createdAt?.millisecondsSinceEpoch ?? 0;
|
||||||
|
return bTs.compareTo(aTs);
|
||||||
|
});
|
||||||
|
_notifications = parsed;
|
||||||
|
} else {
|
||||||
|
_notifications = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch notifications: $e');
|
||||||
|
_notifications = [];
|
||||||
|
} finally {
|
||||||
|
_isNotificationsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dismissNotifications(List<int> notificationIds) async {
|
||||||
|
if (notificationIds.isEmpty) return;
|
||||||
|
try {
|
||||||
|
await api.put('/notifications/dismiss', {
|
||||||
|
"notification_ids": notificationIds,
|
||||||
|
"payload": {"dismissed": true},
|
||||||
|
});
|
||||||
|
_notifications = _notifications
|
||||||
|
.where((n) => !notificationIds.contains(n.id))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to dismiss notifications: $e');
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
lib/services/data_service/data_service_stats.dart
Normal file
28
lib/services/data_service/data_service_stats.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceStats on DataService {
|
||||||
|
Future<void> fetchAboutStats({bool force = false}) async {
|
||||||
|
if (_isAboutStatsLoading) return;
|
||||||
|
if (!force && _aboutStats != null) return;
|
||||||
|
_isAboutStatsLoading = true;
|
||||||
|
_notifyAsync();
|
||||||
|
try {
|
||||||
|
final json = await api.get('/stats/about');
|
||||||
|
if (json is Map<String, dynamic>) {
|
||||||
|
_aboutStats = StatsAbout.fromJson(json);
|
||||||
|
} else if (json is Map) {
|
||||||
|
_aboutStats = StatsAbout.fromJson(
|
||||||
|
json.map((key, value) => MapEntry(key.toString(), value)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Exception('Unexpected stats response: $json');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch about stats: $e');
|
||||||
|
_aboutStats = null;
|
||||||
|
} finally {
|
||||||
|
_isAboutStatsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ extension DataServiceTrips on DataService {
|
|||||||
Future<void> fetchTripDetails() async {
|
Future<void> fetchTripDetails() async {
|
||||||
_isTripDetailsLoading = true;
|
_isTripDetailsLoading = true;
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/legs-and-stats');
|
final json = await api.get('/trips/info');
|
||||||
if (json is List) {
|
final tripDetails = _parseTripInfoList(json);
|
||||||
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
|
_tripDetails = [...tripDetails]..sort((a, b) => b.id.compareTo(a.id));
|
||||||
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
|
_tripList = tripDetails
|
||||||
} else {
|
.map(
|
||||||
_tripDetails = [];
|
(detail) => TripSummary(
|
||||||
}
|
tripId: detail.id,
|
||||||
|
tripName: detail.name,
|
||||||
|
tripMileage: detail.mileage,
|
||||||
|
legCount: detail.legCount,
|
||||||
|
locoStats: detail.locoStats,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch trip_map: $e');
|
debugPrint('Failed to fetch trip_map: $e');
|
||||||
_tripDetails = [];
|
_tripDetails = [];
|
||||||
|
_tripList = [];
|
||||||
} finally {
|
} finally {
|
||||||
_isTripDetailsLoading = false;
|
_isTripDetailsLoading = false;
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
@@ -23,48 +32,17 @@ extension DataServiceTrips on DataService {
|
|||||||
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/stats/$tripId');
|
final json = await api.get('/trips/stats/$tripId');
|
||||||
return _parseTripLocoStats(json);
|
return TripLocoStat.listFromJson(json);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch trip loco stats: $e');
|
debugPrint('Failed to fetch trip loco stats: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
|
|
||||||
List<dynamic>? list;
|
|
||||||
if (json is List) {
|
|
||||||
list = json.expand((e) => e is List ? e : [e]).toList();
|
|
||||||
} else if (json is Map) {
|
|
||||||
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
|
|
||||||
final candidate = json[key];
|
|
||||||
if (candidate is List) {
|
|
||||||
list = candidate.expand((e) => e is List ? e : [e]).toList();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (list == null) return [];
|
|
||||||
return list
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map((e) => TripLocoStat.fromJson(e))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchTrips() async {
|
Future<void> fetchTrips() async {
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/mileage');
|
final json = await api.get('/trips/info');
|
||||||
Iterable<dynamic>? raw;
|
final raw = _extractTrips(json);
|
||||||
if (json is List) {
|
|
||||||
raw = json;
|
|
||||||
} else if (json is Map) {
|
|
||||||
for (final key in ['trips', 'trip_data', 'data']) {
|
|
||||||
final value = json[key];
|
|
||||||
if (value is List) {
|
|
||||||
raw = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (raw != null) {
|
if (raw != null) {
|
||||||
final tripMap = raw
|
final tripMap = raw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
@@ -119,8 +97,9 @@ extension DataServiceTrips on DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void upsertTripSummary(TripSummary trip) {
|
void upsertTripSummary(TripSummary trip) {
|
||||||
final existingIndex =
|
final existingIndex = _tripList.indexWhere(
|
||||||
_tripList.indexWhere((element) => element.tripId == trip.tripId);
|
(element) => element.tripId == trip.tripId,
|
||||||
|
);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
_tripList[existingIndex] = trip;
|
_tripList[existingIndex] = trip;
|
||||||
} else {
|
} else {
|
||||||
@@ -129,4 +108,24 @@ extension DataServiceTrips on DataService {
|
|||||||
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
|
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Iterable<dynamic>? _extractTrips(dynamic json) {
|
||||||
|
if (json is List) return json;
|
||||||
|
if (json is Map) {
|
||||||
|
for (final key in ['trips', 'trip_data', 'data', 'trip_info']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TripDetail> _parseTripInfoList(dynamic json) {
|
||||||
|
final raw = _extractTrips(json);
|
||||||
|
if (raw == null) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => TripDetail.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,49 @@
|
|||||||
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';
|
||||||
import 'package:mileograph_flutter/components/login/login.dart';
|
|
||||||
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
|
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
|
||||||
|
import 'package:mileograph_flutter/components/login/login.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/dashboard.dart';
|
import 'package:mileograph_flutter/components/pages/dashboard.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/legs.dart';
|
|
||||||
import 'package:mileograph_flutter/components/pages/loco_legs.dart';
|
import 'package:mileograph_flutter/components/pages/loco_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/logbook.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/more.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/profile.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/settings.dart';
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/trips.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/navigation_guard.dart';
|
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> _shellNavigatorKey =
|
||||||
|
GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
const List<String> _contentPages = [
|
const List<String> _contentPages = [
|
||||||
"/",
|
"/dashboard",
|
||||||
"/calculator",
|
"/calculator",
|
||||||
"/legs",
|
"/logbook",
|
||||||
"/traction",
|
"/traction",
|
||||||
"/trips",
|
|
||||||
"/add",
|
"/add",
|
||||||
|
"/more",
|
||||||
];
|
];
|
||||||
|
|
||||||
const int _addTabIndex = 5;
|
const List<String> _defaultTabDestinations = [
|
||||||
|
"/dashboard",
|
||||||
|
"/calculator",
|
||||||
|
"/logbook/entries",
|
||||||
|
"/traction",
|
||||||
|
"/add",
|
||||||
|
"/more",
|
||||||
|
];
|
||||||
|
|
||||||
|
const int _addTabIndex = 4;
|
||||||
|
|
||||||
class _NavItem {
|
class _NavItem {
|
||||||
final String label;
|
final String label;
|
||||||
@@ -41,18 +54,32 @@ class _NavItem {
|
|||||||
const List<_NavItem> _navItems = [
|
const List<_NavItem> _navItems = [
|
||||||
_NavItem("Home", Icons.home),
|
_NavItem("Home", Icons.home),
|
||||||
_NavItem("Calculator", Icons.route),
|
_NavItem("Calculator", Icons.route),
|
||||||
_NavItem("Entries", Icons.list),
|
_NavItem("Logbook", Icons.menu_book),
|
||||||
_NavItem("Traction", Icons.train),
|
_NavItem("Traction", Icons.train),
|
||||||
_NavItem("Trips", Icons.book),
|
|
||||||
_NavItem("Add", Icons.add),
|
_NavItem("Add", Icons.add),
|
||||||
|
_NavItem("More", Icons.more_horiz),
|
||||||
];
|
];
|
||||||
|
|
||||||
int tabIndexForPath(String path) {
|
int tabIndexForPath(String path) {
|
||||||
final newIndex = _contentPages.indexWhere((routePath) {
|
var matchPath = path;
|
||||||
if (path == routePath) return true;
|
if (matchPath == '/') matchPath = '/dashboard';
|
||||||
if (routePath == '/') return path == '/';
|
if (matchPath.startsWith('/dashboard')) return 0;
|
||||||
return path.startsWith('$routePath/');
|
if (matchPath.startsWith('/legs')) {
|
||||||
});
|
matchPath = '/logbook/entries';
|
||||||
|
} else if (matchPath.startsWith('/trips')) {
|
||||||
|
matchPath = '/logbook/trips';
|
||||||
|
}
|
||||||
|
if (matchPath.startsWith('/logbook')) {
|
||||||
|
matchPath = '/logbook';
|
||||||
|
} else if (matchPath.startsWith('/profile') ||
|
||||||
|
matchPath.startsWith('/settings') ||
|
||||||
|
matchPath.startsWith('/more')) {
|
||||||
|
matchPath = '/more';
|
||||||
|
}
|
||||||
|
final newIndex = _contentPages.indexWhere(
|
||||||
|
(routePath) =>
|
||||||
|
matchPath == routePath || matchPath.startsWith('$routePath/'),
|
||||||
|
);
|
||||||
return newIndex < 0 ? 0 : newIndex;
|
return newIndex < 0 ? 0 : newIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +107,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
_routerInitialized = true;
|
_routerInitialized = true;
|
||||||
final auth = context.read<AuthService>();
|
final auth = context.read<AuthService>();
|
||||||
_router = GoRouter(
|
_router = GoRouter(
|
||||||
|
initialLocation: '/dashboard',
|
||||||
refreshListenable: auth,
|
refreshListenable: auth,
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final loggedIn = auth.isLoggedIn;
|
final loggedIn = auth.isLoggedIn;
|
||||||
@@ -87,29 +115,59 @@ class _MyAppState extends State<MyApp> {
|
|||||||
final atSettings = state.uri.toString() == '/settings';
|
final atSettings = state.uri.toString() == '/settings';
|
||||||
|
|
||||||
if (!loggedIn && !loggingIn && !atSettings) return '/login';
|
if (!loggedIn && !loggingIn && !atSettings) return '/login';
|
||||||
if (loggedIn && loggingIn) return '/';
|
if (loggedIn && loggingIn) return '/dashboard';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(path: '/', redirect: (context, state) => '/dashboard'),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
navigatorKey: _shellNavigatorKey,
|
navigatorKey: _shellNavigatorKey,
|
||||||
builder: (context, state, child) => MyHomePage(child: child),
|
builder: (context, state, child) => MyHomePage(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => const Dashboard()),
|
GoRoute(
|
||||||
|
path: '/dashboard',
|
||||||
|
builder: (context, state) => const Dashboard(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/calculator',
|
path: '/calculator',
|
||||||
builder: (context, state) => CalculatorPage(),
|
builder: (context, state) => const CalculatorPage(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'details',
|
||||||
|
builder: (context, state) =>
|
||||||
|
CalculatorDetailsPage(result: state.extra),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/calculator/details',
|
path: '/logbook',
|
||||||
builder: (context, state) =>
|
redirect: (context, state) => '/logbook/entries',
|
||||||
CalculatorDetailsPage(result: state.extra),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/logbook/entries',
|
||||||
|
builder: (context, state) => const LogbookPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/logbook/trips',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const LogbookPage(initialTab: LogbookTab.trips),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/trips',
|
||||||
|
redirect: (context, state) => '/logbook/trips',
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/legs',
|
||||||
|
redirect: (context, state) => '/logbook/entries',
|
||||||
),
|
),
|
||||||
GoRoute(path: '/legs', builder: (context, state) => LegsPage()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/traction',
|
path: '/traction',
|
||||||
builder: (context, state) => TractionPage(),
|
builder: (context, state) => TractionPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/profile',
|
||||||
|
builder: (context, state) => const ProfilePage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/traction/:id/timeline',
|
path: '/traction/:id/timeline',
|
||||||
builder: (_, state) {
|
builder: (_, state) {
|
||||||
@@ -146,8 +204,23 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/traction/new',
|
path: '/traction/new',
|
||||||
builder: (context, state) => const NewTractionPage(),
|
builder: (context, state) => const NewTractionPage(),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/trips', builder: (context, state) => TripsPage()),
|
|
||||||
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
|
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more',
|
||||||
|
builder: (context, state) => const MorePage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/profile',
|
||||||
|
builder: (context, state) => const ProfilePage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/stats',
|
||||||
|
builder: (context, state) => const StatsPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/settings',
|
||||||
|
builder: (context, state) => const SettingsPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/legs/edit/:id',
|
path: '/legs/edit/:id',
|
||||||
builder: (_, state) {
|
builder: (_, state) {
|
||||||
@@ -158,7 +231,10 @@ class _MyAppState extends State<MyApp> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
|
GoRoute(
|
||||||
|
path: '/login',
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
builder: (context, state) => const SettingsPage(),
|
builder: (context, state) => const SettingsPage(),
|
||||||
@@ -189,6 +265,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});
|
||||||
@@ -198,23 +282,31 @@ class MyHomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
List<String> get contentPages => _contentPages;
|
List<String> get tabDestinations => _defaultTabDestinations;
|
||||||
|
|
||||||
Future<void> _onItemTapped(int index, int currentIndex) async {
|
Future<void> _onItemTapped(int index, int currentIndex) async {
|
||||||
if (index < 0 || index >= contentPages.length || index == currentIndex) {
|
if (index < 0 || index >= tabDestinations.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final currentPath = GoRouterState.of(context).uri.path;
|
||||||
|
final targetPath = tabDestinations[index];
|
||||||
|
final alreadyAtTarget =
|
||||||
|
currentPath == targetPath || currentPath.startsWith('$targetPath/');
|
||||||
|
if (index == currentIndex && alreadyAtTarget) return;
|
||||||
|
|
||||||
await NavigationGuard.attemptNavigation(() async {
|
await NavigationGuard.attemptNavigation(() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.go(contentPages[index]);
|
_navigateToIndex(index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _lastTabIndex;
|
final List<String> _history = [];
|
||||||
final List<int> _tabHistory = [];
|
int _historyPosition = -1;
|
||||||
bool _handlingBackNavigation = false;
|
final List<String> _forwardHistory = [];
|
||||||
|
bool _suppressRecord = false;
|
||||||
|
|
||||||
bool _fetched = false;
|
bool _fetched = false;
|
||||||
|
bool _railCollapsed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -248,6 +340,9 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
if (data.tripDetails.isEmpty) {
|
if (data.tripDetails.isEmpty) {
|
||||||
data.fetchTripDetails();
|
data.fetchTripDetails();
|
||||||
}
|
}
|
||||||
|
if (data.notifications.isEmpty) {
|
||||||
|
data.fetchNotifications();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -256,149 +351,548 @@ 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(uri.path);
|
||||||
if (pageIndex != _addTabIndex) {
|
if (pageIndex != _addTabIndex) {
|
||||||
NavigationGuard.unregister();
|
NavigationGuard.unregister();
|
||||||
}
|
}
|
||||||
final homepageReady = context.select<DataService, bool>(
|
final homepageReady = context.select<DataService, bool>(
|
||||||
(data) => data.homepageStats != null || !data.isHomepageLoading,
|
(data) => data.homepageStats != null || !data.isHomepageLoading,
|
||||||
);
|
);
|
||||||
|
final data = context.watch<DataService>();
|
||||||
final auth = context.read<AuthService>();
|
final auth = context.read<AuthService>();
|
||||||
|
|
||||||
final currentPage = homepageReady
|
final currentPage = homepageReady
|
||||||
? 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 defaultRailExtended = constraints.maxWidth >= 1400;
|
||||||
|
final railExtended = defaultRailExtended && !_railCollapsed;
|
||||||
|
final showRailToggle = defaultRailExtended;
|
||||||
|
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(
|
||||||
if (!mounted) return;
|
text: "O",
|
||||||
_handlingBackNavigation = true;
|
style: TextStyle(color: Colors.red),
|
||||||
context.go(contentPages[previousTab]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageIndex != 0) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_handlingBackNavigation = true;
|
|
||||||
context.go(contentPages[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SystemNavigator.pop();
|
|
||||||
},
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final isWide = constraints.maxWidth >= 900;
|
|
||||||
final railExtended = constraints.maxWidth >= 1400;
|
|
||||||
final navRailDestinations = _navItems
|
|
||||||
.map(
|
|
||||||
(item) => NavigationRailDestination(
|
|
||||||
icon: Icon(item.icon),
|
|
||||||
label: Text(item.label),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
final navBarDestinations = _navItems
|
|
||||||
.map(
|
|
||||||
(item) => NavigationDestination(
|
|
||||||
icon: Icon(item.icon),
|
|
||||||
label: item.label,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
||||||
title: Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
children: const [
|
|
||||||
TextSpan(text: "Mile"),
|
|
||||||
TextSpan(text: "O", style: TextStyle(color: Colors.red)),
|
|
||||||
TextSpan(text: "graph"),
|
|
||||||
],
|
|
||||||
style: const TextStyle(
|
|
||||||
decoration: TextDecoration.none,
|
|
||||||
color: Colors.white,
|
|
||||||
fontFamily: "Tomatoes",
|
|
||||||
),
|
),
|
||||||
|
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(
|
|
||||||
tooltip: 'Settings',
|
|
||||||
onPressed: () => context.go('/settings'),
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
bottomNavigationBar: isWide
|
actions: [
|
||||||
? null
|
_buildNotificationAction(context, data),
|
||||||
: NavigationBar(
|
IconButton(
|
||||||
selectedIndex: pageIndex,
|
tooltip: 'Settings',
|
||||||
onDestinationSelected: (int index) =>
|
onPressed: () => context.go('/more/settings'),
|
||||||
_onItemTapped(index, pageIndex),
|
icon: const Icon(Icons.settings),
|
||||||
destinations: navBarDestinations,
|
),
|
||||||
),
|
IconButton(
|
||||||
body: isWide
|
onPressed: auth.logout,
|
||||||
? Row(
|
icon: const Icon(Icons.logout),
|
||||||
children: [
|
),
|
||||||
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: LayoutBuilder(
|
||||||
|
builder: (ctx, _) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: showRailToggle ? 56.0 : 0.0,
|
||||||
|
),
|
||||||
|
child: NavigationRail(
|
||||||
|
selectedIndex: pageIndex,
|
||||||
|
extended: railExtended,
|
||||||
|
labelType: railExtended
|
||||||
|
? NavigationRailLabelType.none
|
||||||
|
: NavigationRailLabelType.selected,
|
||||||
|
onDestinationSelected: (int index) =>
|
||||||
|
_onItemTapped(index, pageIndex),
|
||||||
|
destinations: navRailDestinations,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showRailToggle)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 8,
|
||||||
|
child: _buildRailToggleButton(railExtended),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
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) {
|
Widget _buildRailToggleButton(bool railExtended) {
|
||||||
_handlingBackNavigation = false;
|
final collapseIcon = railExtended
|
||||||
_lastTabIndex = pageIndex;
|
? Icons.chevron_left
|
||||||
return;
|
: Icons.chevron_right;
|
||||||
|
final collapseLabel = railExtended ? 'Collapse' : 'Expand';
|
||||||
|
|
||||||
|
if (railExtended) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => setState(() => _railCollapsed = !_railCollapsed),
|
||||||
|
icon: Icon(collapseIcon),
|
||||||
|
label: Text(collapseLabel),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_tabHistory.isEmpty || _tabHistory.last != last) {
|
return Padding(
|
||||||
_tabHistory.add(last);
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(collapseIcon),
|
||||||
|
tooltip: collapseLabel,
|
||||||
|
onPressed: () => setState(() => _railCollapsed = !_railCollapsed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationAction(BuildContext context, DataService data) {
|
||||||
|
final count = data.notifications.length;
|
||||||
|
final hasBadge = count > 0;
|
||||||
|
final badgeText = count > 9 ? '9+' : '$count';
|
||||||
|
final isLoading = data.isNotificationsLoading;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Notifications',
|
||||||
|
onPressed: () => _openNotificationsPanel(context),
|
||||||
|
icon: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.notifications_none),
|
||||||
|
),
|
||||||
|
if (hasBadge)
|
||||||
|
Positioned(
|
||||||
|
right: 6,
|
||||||
|
top: 8,
|
||||||
|
child: IgnorePointer(child: _buildBadge(badgeText)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openNotificationsPanel(BuildContext context) async {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final isWide = MediaQuery.sizeOf(context).width >= 900;
|
||||||
|
final sheetHeight = MediaQuery.sizeOf(context).height * 0.9;
|
||||||
|
try {
|
||||||
|
await data.fetchNotifications();
|
||||||
|
} catch (_) {
|
||||||
|
// Already logged inside data service.
|
||||||
}
|
}
|
||||||
_lastTabIndex = pageIndex;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: _buildNotificationsContent(dialogCtx, isWide),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (sheetCtx) {
|
||||||
|
return SizedBox(
|
||||||
|
height: sheetHeight,
|
||||||
|
child: SafeArea(
|
||||||
|
child: _buildNotificationsContent(sheetCtx, isWide),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationsContent(BuildContext context, bool isWide) {
|
||||||
|
final data = context.watch<DataService>();
|
||||||
|
final notifications = data.notifications;
|
||||||
|
final loading = data.isNotificationsLoading;
|
||||||
|
final listHeight = isWide
|
||||||
|
? 380.0
|
||||||
|
: MediaQuery.of(context).size.height * 0.6;
|
||||||
|
|
||||||
|
Widget body;
|
||||||
|
if (loading && notifications.isEmpty) {
|
||||||
|
body = const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (notifications.isEmpty) {
|
||||||
|
body = const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: Text('No notifications right now.'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = SizedBox(
|
||||||
|
height: listHeight,
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: notifications.length,
|
||||||
|
separatorBuilder: (_, index) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, index) {
|
||||||
|
final item = notifications[index];
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title.isNotEmpty
|
||||||
|
? item.title
|
||||||
|
: 'Notification',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
item.body,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
if (item.createdAt != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_formatNotificationTime(item.createdAt!),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: () {
|
||||||
|
final baseColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.color;
|
||||||
|
if (baseColor == null) return null;
|
||||||
|
final newAlpha = (baseColor.a * 0.7)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
return baseColor.withValues(
|
||||||
|
alpha: newAlpha,
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
_dismissNotifications(context, [item.id]),
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: isWide ? 420 : double.infinity,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Notifications',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: notifications.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _dismissNotifications(
|
||||||
|
context,
|
||||||
|
notifications.map((e) => e.id).toList(),
|
||||||
|
),
|
||||||
|
child: const Text('Dismiss all'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
body,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismissNotifications(
|
||||||
|
BuildContext context,
|
||||||
|
List<int> ids,
|
||||||
|
) async {
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
try {
|
||||||
|
await context.read<DataService>().dismissNotifications(ids);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(SnackBar(content: Text('Failed to dismiss: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatNotificationTime(DateTime dateTime) {
|
||||||
|
final y = dateTime.year.toString().padLeft(4, '0');
|
||||||
|
final m = dateTime.month.toString().padLeft(2, '0');
|
||||||
|
final d = dateTime.day.toString().padLeft(2, '0');
|
||||||
|
final hh = dateTime.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = dateTime.minute.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d $hh:$mm';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBadge(String label) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(minWidth: 20),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _handleBackNavigation({
|
||||||
|
bool allowExit = false,
|
||||||
|
bool recordForward = false,
|
||||||
|
}) async {
|
||||||
|
final currentPath = GoRouterState.of(context).uri.path;
|
||||||
|
final shellNav = _shellNavigatorKey.currentState;
|
||||||
|
if (shellNav != null && shellNav.canPop()) {
|
||||||
|
if (recordForward) _pushForward(currentPath);
|
||||||
|
_alignHistoryAfterPop(currentPath);
|
||||||
|
shellNav.pop();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_historyPosition > 0) {
|
||||||
|
if (recordForward) _pushForward(currentPath);
|
||||||
|
_historyPosition -= 1;
|
||||||
|
_suppressRecord = true;
|
||||||
|
context.go(_history[_historyPosition]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final homePath = tabDestinations.first;
|
||||||
|
if (currentPath != homePath) {
|
||||||
|
if (recordForward) _pushForward(currentPath);
|
||||||
|
_suppressRecord = true;
|
||||||
|
context.go(homePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowExit) {
|
||||||
|
SystemNavigator.pop();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _handleForwardNavigation() async {
|
||||||
|
if (_forwardHistory.isEmpty) return false;
|
||||||
|
final nextPath = _forwardHistory.removeLast();
|
||||||
|
|
||||||
|
// Move cursor forward, keeping history in sync.
|
||||||
|
if (_historyPosition < _history.length - 1) {
|
||||||
|
_historyPosition += 1;
|
||||||
|
_history[_historyPosition] = nextPath;
|
||||||
|
if (_historyPosition < _history.length - 1) {
|
||||||
|
_history.removeRange(_historyPosition + 1, _history.length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_history.add(nextPath);
|
||||||
|
_historyPosition = _history.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressRecord = true;
|
||||||
|
if (!mounted) return false;
|
||||||
|
context.go(nextPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pushForward(String path) {
|
||||||
|
if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
|
||||||
|
_forwardHistory.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _alignHistoryAfterPop(String currentPath) {
|
||||||
|
if (_history.isEmpty) return;
|
||||||
|
if (_historyPosition >= 0 &&
|
||||||
|
_historyPosition < _history.length &&
|
||||||
|
_history[_historyPosition] == currentPath) {
|
||||||
|
if (_historyPosition > 0) {
|
||||||
|
_historyPosition -= 1;
|
||||||
|
}
|
||||||
|
_history.removeRange(_historyPosition + 1, _history.length);
|
||||||
|
_suppressRecord = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncHistory(String path) {
|
||||||
|
if (_history.isEmpty) {
|
||||||
|
_history.add(path);
|
||||||
|
_historyPosition = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_suppressRecord) {
|
||||||
|
_suppressRecord = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_historyPosition >= 0 &&
|
||||||
|
_historyPosition < _history.length &&
|
||||||
|
_history[_historyPosition] == path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_historyPosition < _history.length - 1) {
|
||||||
|
_history.removeRange(_historyPosition + 1, _history.length);
|
||||||
|
}
|
||||||
|
_history.add(path);
|
||||||
|
_historyPosition = _history.length - 1;
|
||||||
|
_forwardHistory.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToIndex(int index) {
|
||||||
|
_suppressRecord = false;
|
||||||
|
_forwardHistory.clear();
|
||||||
|
context.go(tabDestinations[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.3+1
|
version: 0.5.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ void main() {
|
|||||||
expect(tabIndexForPath('/calculator/details'), 1);
|
expect(tabIndexForPath('/calculator/details'), 1);
|
||||||
expect(tabIndexForPath('/legs'), 2);
|
expect(tabIndexForPath('/legs'), 2);
|
||||||
expect(tabIndexForPath('/traction/12/timeline'), 3);
|
expect(tabIndexForPath('/traction/12/timeline'), 3);
|
||||||
expect(tabIndexForPath('/trips'), 4);
|
expect(tabIndexForPath('/trips'), 2);
|
||||||
expect(tabIndexForPath('/add'), 5);
|
expect(tabIndexForPath('/add'), 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tabIndexForPath ignores query when parsing uri', () {
|
test('tabIndexForPath ignores query when parsing uri', () {
|
||||||
expect(tabIndexForPath(Uri.parse('/trips?sort=desc').path), 4);
|
expect(tabIndexForPath(Uri.parse('/trips?sort=desc').path), 2);
|
||||||
expect(tabIndexForPath(Uri.parse('/calculator/details?x=1').path), 1);
|
expect(tabIndexForPath(Uri.parse('/calculator/details?x=1').path), 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user