Compare commits
8 Commits
v0.4.0-dev
...
v0.5.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c15546b66 | |||
| e1ad1ea685 | |||
| 9b307ab56b | |||
| 8cf43c76e2 | |||
| 2600e90efa | |||
| a9bc6c306c | |||
| 54026aa93a | |||
| 0971124fd4 |
@@ -10,7 +10,7 @@ env:
|
||||
JAVA_VERSION: "17"
|
||||
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
|
||||
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
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -27,8 +27,13 @@ class App extends StatelessWidget {
|
||||
ChangeNotifierProvider<AuthService>(
|
||||
create: (context) => AuthService(api: context.read<ApiService>()),
|
||||
),
|
||||
ChangeNotifierProvider<DataService>(
|
||||
ChangeNotifierProxyProvider<AuthService, DataService>(
|
||||
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(),
|
||||
|
||||
@@ -51,7 +51,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Latest loco changes',
|
||||
'Latest Loco Changes',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
@@ -286,7 +286,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
],
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(height: 8),
|
||||
separatorBuilder: (_, index) => const Divider(height: 8),
|
||||
itemCount: grouped.length,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
@@ -38,19 +36,72 @@ class _LegCardState extends State<LegCard> {
|
||||
title: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 520;
|
||||
final routeText = Text('${leg.start} → ${leg.end}');
|
||||
final timeText =
|
||||
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
|
||||
final beginTimeWidget = _timeWithDelay(
|
||||
context,
|
||||
leg.beginTime,
|
||||
leg.beginDelayMinutes,
|
||||
includeDate: widget.showDate,
|
||||
);
|
||||
final endTimeWidget = leg.endTime == null
|
||||
? null
|
||||
: _timeWithDelay(
|
||||
context,
|
||||
leg.endTime!,
|
||||
leg.endDelayMinutes,
|
||||
includeDate: widget.showDate,
|
||||
);
|
||||
|
||||
final routeText = Text(
|
||||
'${leg.start} → ${leg.end}',
|
||||
softWrap: true,
|
||||
);
|
||||
if (!isWide) {
|
||||
return routeText;
|
||||
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 Row(
|
||||
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
timeText,
|
||||
const SizedBox(width: 6),
|
||||
beginTimeWidget,
|
||||
const Text('·'),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: routeText),
|
||||
routeText,
|
||||
if (endTimeWidget != null) ...[
|
||||
const Text('·'),
|
||||
endTimeWidget,
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -58,8 +109,12 @@ class _LegCardState extends State<LegCard> {
|
||||
subtitle: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 520;
|
||||
final timeWidget =
|
||||
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
|
||||
final timeWidget = _timeWithDelay(
|
||||
context,
|
||||
leg.beginTime,
|
||||
leg.beginDelayMinutes,
|
||||
includeDate: widget.showDate,
|
||||
);
|
||||
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
||||
? Wrap(
|
||||
spacing: 8,
|
||||
@@ -90,9 +145,7 @@ class _LegCardState extends State<LegCard> {
|
||||
children.add(tractionWrap);
|
||||
}
|
||||
} else {
|
||||
children.add(timeWidget);
|
||||
if (tractionWrap != null) {
|
||||
children.add(const SizedBox(height: 4));
|
||||
children.add(tractionWrap);
|
||||
}
|
||||
}
|
||||
@@ -191,6 +244,12 @@ class _LegCardState extends State<LegCard> {
|
||||
),
|
||||
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) ...[
|
||||
Text('Route', style: textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
@@ -224,6 +283,7 @@ class _LegCardState extends State<LegCard> {
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
final data = context.read<DataService>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
@@ -237,6 +297,40 @@ class _LegCardState extends State<LegCard> {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (date == null) return '';
|
||||
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
@@ -285,6 +379,51 @@ class _LegCardState extends State<LegCard> {
|
||||
.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) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -305,38 +444,7 @@ class _LegCardState extends State<LegCard> {
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _parseRouteSegments(String route) {
|
||||
final trimmed = route.trim();
|
||||
if (trimmed.isEmpty) return [];
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
final replaced = trimmed.replaceAll("'", '"');
|
||||
final decoded = jsonDecode(replaced);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (trimmed.contains('->')) {
|
||||
return trimmed
|
||||
.split('->')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
if (trimmed.contains(',')) {
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [trimmed];
|
||||
List<String> _parseRouteSegments(List<String> route) {
|
||||
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkExistingSession());
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _checkExistingSession(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkExistingSession() async {
|
||||
@@ -71,15 +73,6 @@ 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 LoginPanel(),
|
||||
const SizedBox(height: 16),
|
||||
@@ -95,6 +88,24 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -193,9 +204,9 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
|
||||
setState(() {
|
||||
_loggingIn = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Login failed: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Login failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,14 +317,16 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Registration successful. Please log in.')),
|
||||
const SnackBar(
|
||||
content: Text('Registration successful. Please log in.'),
|
||||
),
|
||||
);
|
||||
widget.onBack();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Registration failed: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _registering = false);
|
||||
}
|
||||
|
||||
@@ -299,7 +299,6 @@ class _FieldInput extends StatelessWidget {
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') {
|
||||
final unit = entry.unit ?? 'kph';
|
||||
final isNumber = true;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -307,7 +306,7 @@ class _FieldInput extends StatelessWidget {
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(isNumber ? parsed : val, unit: unit);
|
||||
onChanged(parsed, unit: unit);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
|
||||
@@ -21,8 +21,7 @@ class LogbookPage extends StatelessWidget {
|
||||
children: [
|
||||
TabBar(
|
||||
onTap: (index) {
|
||||
final dest =
|
||||
index == 0 ? '/logbook/entries' : '/logbook/trips';
|
||||
final dest = index == 0 ? '/logbook/entries' : '/logbook/trips';
|
||||
final current = GoRouterState.of(context).uri.path;
|
||||
if (current != dest) {
|
||||
context.go(dest);
|
||||
@@ -34,12 +33,7 @@ class LogbookPage extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: const [
|
||||
LegsPage(),
|
||||
TripsPage(),
|
||||
],
|
||||
),
|
||||
child: TabBarView(children: const [LegsPage(), TripsPage()]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -13,6 +13,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
if (choice == _ExitChoice.save) {
|
||||
await _saveDraftEntry(draftId: _activeDraftId);
|
||||
} 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);
|
||||
_activeDraftId = null;
|
||||
}
|
||||
@@ -29,12 +32,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
}
|
||||
|
||||
bool _formIsEmpty() {
|
||||
final beginDelayVal = _parseDelayMinutes(_beginDelayController.text);
|
||||
final endDelayVal = _parseDelayMinutes(_endDelayController.text);
|
||||
return _startController.text.trim().isEmpty &&
|
||||
_endController.text.trim().isEmpty &&
|
||||
_headcodeController.text.trim().isEmpty &&
|
||||
_notesController.text.trim().isEmpty &&
|
||||
_networkController.text.trim().isEmpty &&
|
||||
_mileageController.text.trim().isEmpty &&
|
||||
_originController.text.trim().isEmpty &&
|
||||
_destinationController.text.trim().isEmpty &&
|
||||
beginDelayVal == 0 &&
|
||||
endDelayVal == 0 &&
|
||||
!_hasOriginTime &&
|
||||
!_hasDestinationTime &&
|
||||
!_hasEndTime &&
|
||||
_routeResult == null &&
|
||||
_tractionItems.length <= 1;
|
||||
}
|
||||
@@ -122,6 +134,30 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
"notes": _notesController.text,
|
||||
"mileage": _mileageController.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,
|
||||
"selectedTripId": _selectedTripId,
|
||||
"routeResult": _routeResult == null
|
||||
@@ -200,6 +236,12 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
bool includeTimestamp = true,
|
||||
}) {
|
||||
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
|
||||
? _startController.text.trim()
|
||||
: (routeStations.isNotEmpty ? routeStations.first : '');
|
||||
@@ -210,27 +252,33 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
? double.tryParse(_mileageController.text.trim()) ?? 0
|
||||
: (_routeResult?.distance ?? 0);
|
||||
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
|
||||
? {
|
||||
"leg_trip": _selectedTripId,
|
||||
...commonPayload,
|
||||
"leg_start": startVal,
|
||||
"leg_end": endVal,
|
||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
||||
"leg_network": _networkController.text.trim(),
|
||||
"leg_distance": mileageVal,
|
||||
"isKilometers": false,
|
||||
"leg_notes": _notesController.text.trim(),
|
||||
"leg_headcode": _headcodeController.text.trim(),
|
||||
"locos": tractionPayload,
|
||||
}
|
||||
: {
|
||||
"leg_trip": _selectedTripId,
|
||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
||||
...commonPayload,
|
||||
"leg_route": routeStations,
|
||||
"leg_notes": _notesController.text.trim(),
|
||||
"leg_headcode": _headcodeController.text.trim(),
|
||||
"leg_network": _networkController.text.trim(),
|
||||
"locos": tractionPayload,
|
||||
"leg_mileage": _routeResult?.distance ?? mileageVal,
|
||||
};
|
||||
return {
|
||||
@@ -265,6 +313,29 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
final beginTime = beginStr == null
|
||||
? 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 tripId = tripRaw is num ? tripRaw.toInt() : null;
|
||||
|
||||
@@ -312,6 +383,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
_useManualMileage = useManual;
|
||||
_selectedDate = 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;
|
||||
_routeResult = restoredRouteResult;
|
||||
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
|
||||
@@ -319,6 +405,10 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
_networkController.text = (payload['leg_network'] as String? ?? '')
|
||||
.toUpperCase();
|
||||
_notesController.text = payload['leg_notes'] ?? '';
|
||||
_originController.text = origin;
|
||||
_destinationController.text = destination;
|
||||
_beginDelayController.text = beginDelay.toString();
|
||||
_endDelayController.text = endDelay.toString();
|
||||
|
||||
if (useManual) {
|
||||
_startController.text = payload['leg_start'] ?? '';
|
||||
@@ -359,6 +449,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
||||
includeTimestamp: false,
|
||||
);
|
||||
_restoringDraft = false;
|
||||
_scheduleMatchUpdate();
|
||||
}
|
||||
|
||||
Future<void> _loadDraft() async {
|
||||
|
||||
@@ -14,15 +14,32 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
TimeOfDay _selectedTime = TimeOfDay.now();
|
||||
DateTime _selectedEndDate = DateTime.now();
|
||||
TimeOfDay _selectedEndTime = TimeOfDay.now();
|
||||
final _startController = TextEditingController();
|
||||
final _endController = TextEditingController();
|
||||
final _headcodeController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
final _mileageController = 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 _useManualMileage = false;
|
||||
bool _hasEndTime = false;
|
||||
bool _matchOriginToEntry = false;
|
||||
bool _matchDestinationToEntry = false;
|
||||
RouteResult? _routeResult;
|
||||
List<Station> _stations = const [];
|
||||
bool _loadingStations = false;
|
||||
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
||||
int? _selectedTripId;
|
||||
bool _restoringDraft = false;
|
||||
@@ -50,9 +67,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
final data = context.read<DataService>();
|
||||
data.fetchClassList();
|
||||
data.fetchTripOptions();
|
||||
_loadStations();
|
||||
if (_draftPersistenceEnabled) {
|
||||
_loadDraft();
|
||||
}
|
||||
_loadStations();
|
||||
if (_isEditing && widget.editLegId != null) {
|
||||
_loadLegForEdit(widget.editLegId!);
|
||||
}
|
||||
@@ -68,6 +87,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
_notesController.dispose();
|
||||
_mileageController.dispose();
|
||||
_networkController.dispose();
|
||||
_originController.dispose();
|
||||
_destinationController.dispose();
|
||||
_beginDelayController.dispose();
|
||||
_endDelayController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -136,7 +159,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()),
|
||||
onPressed: () =>
|
||||
Navigator.of(dialogContext).pop(controller.text.trim()),
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
@@ -147,15 +171,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
return;
|
||||
}
|
||||
if (result != null && result.isNotEmpty) {
|
||||
final api = context.read<ApiService>();
|
||||
final data = context.read<DataService>();
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
try {
|
||||
final encoded = Uri.encodeComponent(result);
|
||||
final res = await api.put('/trips/new?trip_name=$encoded', {});
|
||||
await data.fetchTripOptions();
|
||||
if (!context.mounted) return;
|
||||
final trips = data.tripList;
|
||||
final api = context.read<ApiService>();
|
||||
final data = context.read<DataService>();
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
try {
|
||||
final encoded = Uri.encodeComponent(result);
|
||||
final res = await api.put('/trips/new?trip_name=$encoded', {});
|
||||
await data.fetchTripOptions();
|
||||
if (!context.mounted) return;
|
||||
final trips = data.tripList;
|
||||
final apiTripId = res is Map ? res['trip_id'] as int? : null;
|
||||
TripSummary match;
|
||||
try {
|
||||
@@ -206,6 +230,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
_useManualMileage = false;
|
||||
});
|
||||
_saveDraft();
|
||||
_scheduleMatchUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +243,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
);
|
||||
if (picked != null) setState(() => _selectedDate = picked);
|
||||
_saveDraft();
|
||||
_scheduleMatchUpdate();
|
||||
}
|
||||
|
||||
Future<void> _pickTime() async {
|
||||
@@ -229,6 +255,158 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
setState(() => _selectedTime = picked);
|
||||
_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 {
|
||||
@@ -245,6 +423,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
}
|
||||
final beginTime =
|
||||
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
|
||||
final endTime = DateTime.tryParse(json['leg_end_time'] ?? '');
|
||||
final routeStations = _parseRouteStations(json['leg_route']);
|
||||
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
||||
final useManual = routeStations.isEmpty;
|
||||
@@ -262,6 +441,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.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;
|
||||
setState(() {
|
||||
@@ -270,6 +459,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
||||
_selectedDate = 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;
|
||||
_routeResult = routeResult;
|
||||
_startController.text = json['leg_start'] ?? '';
|
||||
@@ -279,6 +478,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
_notesController.text = json['leg_notes'] ?? '';
|
||||
_networkController.text = (json['leg_network'] as String? ?? '')
|
||||
.toUpperCase();
|
||||
_originController.text = origin;
|
||||
_destinationController.text = destination;
|
||||
_beginDelayController.text = beginDelay.toString();
|
||||
_endDelayController.text = endDelay.toString();
|
||||
_mileageController.text = mileageVal == 0
|
||||
? ''
|
||||
: mileageVal.toStringAsFixed(2);
|
||||
@@ -357,6 +560,161 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
Widget body;
|
||||
@@ -376,7 +734,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
|
||||
final balancedHeight = balancePanels ? 165.0 : null;
|
||||
|
||||
final detailPanel = _section('Details', [
|
||||
final entryPanel = _section('Entry', [
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
@@ -424,31 +782,46 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
],
|
||||
),
|
||||
_buildTripSelector(context),
|
||||
_dateTimeGroup(
|
||||
context,
|
||||
title: 'Departure time',
|
||||
onDateTap: _pickDate,
|
||||
onTimeTap: _pickTime,
|
||||
selectedDate: _selectedDate,
|
||||
selectedTime: _selectedTime,
|
||||
delayController: _beginDelayController,
|
||||
singleColumn: isMobile,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _pickDate,
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
label: Text(DateFormat.yMMMd().format(_selectedDate)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _pickTime,
|
||||
icon: const Icon(Icons.schedule),
|
||||
label: Text(_selectedTime.format(context)),
|
||||
),
|
||||
Checkbox(
|
||||
value: _hasEndTime,
|
||||
onChanged: _submitting ? null : _toggleEndTime,
|
||||
),
|
||||
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)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _startController,
|
||||
onChanged: (_) {
|
||||
_saveDraft();
|
||||
_scheduleMatchUpdate();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'From',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -462,6 +835,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _endController,
|
||||
onChanged: (_) {
|
||||
_saveDraft();
|
||||
_scheduleMatchUpdate();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'To',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -473,6 +850,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
final trainPanel = _section('Train', [
|
||||
TextFormField(
|
||||
controller: _headcodeController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
@@ -482,6 +862,43 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
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(
|
||||
controller: _networkController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
@@ -519,14 +936,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
if (!_useManualMileage)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _openCalculator,
|
||||
icon: const Icon(Icons.calculate, size: 18),
|
||||
label: const Text('Open mileage calculator'),
|
||||
@@ -565,6 +975,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
onSelected: (val) {
|
||||
setState(() => _useManualMileage = val);
|
||||
_saveDraft();
|
||||
_scheduleMatchUpdate();
|
||||
},
|
||||
),
|
||||
minHeight: balancedHeight,
|
||||
@@ -575,7 +986,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
detailPanel,
|
||||
entryPanel,
|
||||
const SizedBox(height: 16),
|
||||
trainPanel,
|
||||
const SizedBox(height: 16),
|
||||
twoCol
|
||||
? 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(
|
||||
String title,
|
||||
List<Widget> children, {
|
||||
@@ -781,6 +1338,154 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -62,6 +62,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
? double.tryParse(_mileageController.text.trim()) ?? 0
|
||||
: (_routeResult?.distance ?? 0);
|
||||
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(
|
||||
routeStations: routeStations,
|
||||
startVal: startVal,
|
||||
@@ -82,19 +88,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
final isEditingExisting = _isEditing && widget.editLegId != null;
|
||||
|
||||
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) {
|
||||
final body = {
|
||||
if (isEditingExisting) "leg_id": widget.editLegId,
|
||||
"leg_trip": _selectedTripId,
|
||||
...commonPayload,
|
||||
"leg_start": startVal,
|
||||
"leg_end": endVal,
|
||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
||||
"leg_network": _networkController.text.trim(),
|
||||
"leg_distance": mileageVal,
|
||||
"isKilometers": false,
|
||||
"leg_notes": _notesController.text.trim(),
|
||||
"leg_headcode": _headcodeController.text.trim(),
|
||||
"locos": tractionPayload,
|
||||
};
|
||||
if (isEditingExisting) {
|
||||
await api.put('/update', body);
|
||||
@@ -103,14 +121,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
}
|
||||
} else {
|
||||
final body = {
|
||||
if (isEditingExisting) "leg_id": widget.editLegId,
|
||||
"leg_trip": _selectedTripId,
|
||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
||||
...commonPayload,
|
||||
"leg_route": routeStations,
|
||||
"leg_notes": _notesController.text.trim(),
|
||||
"leg_headcode": _headcodeController.text.trim(),
|
||||
"leg_network": _networkController.text.trim(),
|
||||
"locos": tractionPayload,
|
||||
};
|
||||
if (isEditingExisting) {
|
||||
await api.put('/update', body);
|
||||
@@ -148,18 +160,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
required double mileageVal,
|
||||
required List<Map<String, dynamic>> tractionPayload,
|
||||
}) {
|
||||
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
|
||||
final endDelay =
|
||||
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
|
||||
return {
|
||||
"legId": widget.editLegId,
|
||||
"useManualMileage": _useManualMileage,
|
||||
"tripId": _selectedTripId,
|
||||
"legDateTime": _legDateTime.toIso8601String(),
|
||||
"legEndTime": _legEndDateTime?.toIso8601String(),
|
||||
"hasEndTime": _hasEndTime,
|
||||
"legOriginTime": _originDateTime?.toIso8601String(),
|
||||
"hasOriginTime": _hasOriginTime,
|
||||
"legDestinationTime": _destinationDateTime?.toIso8601String(),
|
||||
"hasDestinationTime": _hasDestinationTime,
|
||||
"start": startVal,
|
||||
"end": endVal,
|
||||
"origin": _originController.text.trim(),
|
||||
"destination": _destinationController.text.trim(),
|
||||
"routeStations": routeStations,
|
||||
"mileage": mileageVal,
|
||||
"network": _networkController.text.trim(),
|
||||
"notes": _notesController.text.trim(),
|
||||
"headcode": _headcodeController.text.trim(),
|
||||
"beginDelay": beginDelay,
|
||||
"endDelay": endDelay,
|
||||
"locos": tractionPayload,
|
||||
"routeResult": _routeResult == null
|
||||
? null
|
||||
@@ -202,11 +227,27 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
_notesController.clear();
|
||||
_mileageController.clear();
|
||||
_networkController.clear();
|
||||
_originController.clear();
|
||||
_destinationController.clear();
|
||||
_beginDelayController.text = '0';
|
||||
_endDelayController.text = '0';
|
||||
final now = DateTime.now();
|
||||
_setState(() {
|
||||
_selectedDate = 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;
|
||||
_hasEndTime = false;
|
||||
_hasOriginTime = false;
|
||||
_hasDestinationTime = false;
|
||||
_matchOriginToEntry = false;
|
||||
_matchDestinationToEntry = false;
|
||||
_matchUpdateScheduled = false;
|
||||
_routeResult = null;
|
||||
_tractionItems
|
||||
..clear()
|
||||
|
||||
@@ -13,6 +13,10 @@ class ProfilePage extends StatefulWidget {
|
||||
|
||||
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() {
|
||||
@@ -23,7 +27,15 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
}
|
||||
|
||||
Future<void> _refreshAwards() {
|
||||
return context.read<DataService>().fetchBadgeAwards();
|
||||
_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
|
||||
@@ -31,6 +43,14 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
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(
|
||||
@@ -52,27 +72,189 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (loading && awards.isEmpty)
|
||||
if ((loading || classProgressLoading || locoProgressLoading) &&
|
||||
!hasAnyData)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (awards.isEmpty)
|
||||
else if (!hasAnyData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Text('No badges awarded yet.'),
|
||||
)
|
||||
else
|
||||
...awards.map((award) => _buildAwardCard(context, award)),
|
||||
..._buildGroupedAwards(
|
||||
context,
|
||||
awards,
|
||||
classProgress,
|
||||
locoProgress,
|
||||
classProgressLoading,
|
||||
locoProgressLoading,
|
||||
data.classClearanceHasMore,
|
||||
data.locoClearanceHasMore,
|
||||
data.badgeAwardsHasMore,
|
||||
loading,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAwardCard(BuildContext context, BadgeAward award) {
|
||||
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)
|
||||
@@ -80,46 +262,52 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
final tierIcon = _buildTierIcon(award.badgeTier);
|
||||
final scope = _scopeToShow(award);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
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: 6),
|
||||
Text(
|
||||
scope,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
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 (award.loco != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildLocoInfo(context, award.loco!),
|
||||
],
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -169,6 +357,316 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
.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');
|
||||
@@ -176,7 +674,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
return '$y-$m-$d';
|
||||
}
|
||||
|
||||
Widget? _buildTierIcon(String tier) {
|
||||
Widget? _buildTierIcon(String tier, {double size = 24}) {
|
||||
final lower = tier.toLowerCase();
|
||||
Color? color;
|
||||
switch (lower) {
|
||||
@@ -191,7 +689,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
break;
|
||||
}
|
||||
if (color == null) return null;
|
||||
return Icon(Icons.emoji_events, color: color);
|
||||
return Icon(Icons.emoji_events, color: color, size: size);
|
||||
}
|
||||
|
||||
String? _scopeToShow(BadgeAward award) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mileograph_flutter/services/authservice.dart';
|
||||
import 'package:mileograph_flutter/services/api_service.dart';
|
||||
import 'package:mileograph_flutter/services/endpoint_service.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -17,16 +19,27 @@ class SettingsPage extends StatefulWidget {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
late final TextEditingController _endpointController;
|
||||
bool _saving = false;
|
||||
bool _changingPassword = false;
|
||||
final _passwordFormKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _currentPasswordController;
|
||||
late final TextEditingController _newPasswordController;
|
||||
late final TextEditingController _confirmPasswordController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final endpoint = context.read<EndpointService>().baseUrl;
|
||||
_endpointController = TextEditingController(text: endpoint);
|
||||
_currentPasswordController = TextEditingController();
|
||||
_newPasswordController = TextEditingController();
|
||||
_confirmPasswordController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPasswordController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_endpointController.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
|
||||
Widget build(BuildContext context) {
|
||||
final endpointService = context.watch<EndpointService>();
|
||||
final loggedIn = context.select<AuthService, bool>(
|
||||
(auth) => auth.isLoggedIn,
|
||||
);
|
||||
if (!endpointService.isLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
@@ -149,7 +199,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -205,6 +255,99 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
'Current: ${endpointService.baseUrl}',
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -27,10 +27,6 @@ class _TripsPageState extends State<TripsPage> {
|
||||
_tripLocoStatsFutures.clear();
|
||||
final data = context.read<DataService>();
|
||||
await data.fetchTripDetails();
|
||||
if (!mounted) return;
|
||||
for (final trip in data.tripDetails) {
|
||||
_tripStatsFuture(trip.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
||||
@@ -42,10 +38,7 @@ class _TripsPageState extends State<TripsPage> {
|
||||
"trip_id": trip.id,
|
||||
"trip_name": newName,
|
||||
});
|
||||
await Future.wait([
|
||||
data.fetchTripDetails(),
|
||||
data.fetchTrips(),
|
||||
]);
|
||||
await data.fetchTripDetails();
|
||||
} catch (e) {
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Failed to rename trip: $e')),
|
||||
@@ -54,10 +47,24 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TripLocoStat>> _tripStatsFuture(int tripId) {
|
||||
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(
|
||||
tripId,
|
||||
() => context.read<DataService>().fetchTripLocoStats(tripId),
|
||||
trip.id,
|
||||
() => context.read<DataService>().fetchTripLocoStats(trip.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +100,10 @@ class _TripsPageState extends State<TripsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
final tripDetails = data.tripDetails;
|
||||
final tripSummaries = data.trips;
|
||||
final tripSummaries = data.tripList;
|
||||
final summaryById = {
|
||||
for (final summary in tripSummaries) summary.tripId: summary,
|
||||
};
|
||||
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
|
||||
|
||||
return RefreshIndicator(
|
||||
@@ -184,18 +194,25 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
|
||||
final trip = tripDetails[index - 1];
|
||||
return _buildTripCard(context, trip);
|
||||
final summary = summaryById[trip.id];
|
||||
return _buildTripCard(context, trip, summary);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTripCard(BuildContext context, TripDetail trip) {
|
||||
Widget _buildTripCard(
|
||||
BuildContext context,
|
||||
TripDetail trip,
|
||||
TripSummary? summary,
|
||||
) {
|
||||
final legs = trip.legs;
|
||||
final legCount = trip.legCount > 0 ? trip.legCount : legs.length;
|
||||
final legCount =
|
||||
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
|
||||
final dateRange = _formatDateRange(legs);
|
||||
final endpoints = _formatEndpoints(legs);
|
||||
final statsFuture = _tripStatsFuture(trip.id);
|
||||
final stats = _cachedTripStats(trip, summary);
|
||||
final winnerCount = stats.where((e) => e.won).length;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -245,50 +262,25 @@ class _TripsPageState extends State<TripsPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FutureBuilder<List<TripLocoStat>>(
|
||||
future: statsFuture,
|
||||
builder: (context, snapshot) {
|
||||
final chips = <Widget>[
|
||||
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
|
||||
if (dateRange != null)
|
||||
_buildMetaChip(context, Icons.calendar_month, dateRange),
|
||||
if (endpoints != null)
|
||||
_buildMetaChip(context, Icons.route, endpoints),
|
||||
];
|
||||
|
||||
final stats = snapshot.data ?? const [];
|
||||
final hasStats = stats.isNotEmpty;
|
||||
final loading =
|
||||
snapshot.connectionState == ConnectionState.waiting;
|
||||
|
||||
if (loading && !hasStats) {
|
||||
chips.add(
|
||||
_buildMetaChip(context, Icons.train, 'Loading traction...'),
|
||||
);
|
||||
} else if (hasStats) {
|
||||
final winnerCount = stats.where((e) => e.won).length;
|
||||
chips.add(
|
||||
_buildMetaChip(context, Icons.train, '${stats.length} had'),
|
||||
);
|
||||
chips.add(
|
||||
_buildMetaChip(
|
||||
context,
|
||||
Icons.emoji_events_outlined,
|
||||
'$winnerCount winners',
|
||||
),
|
||||
);
|
||||
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||
chips.add(
|
||||
_buildMetaChip(context, Icons.train, 'No traction yet'),
|
||||
);
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: chips,
|
||||
);
|
||||
},
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
|
||||
if (dateRange != null)
|
||||
_buildMetaChip(context, Icons.calendar_month, dateRange),
|
||||
if (endpoints != null)
|
||||
_buildMetaChip(context, Icons.route, endpoints),
|
||||
if (stats.isNotEmpty) ...[
|
||||
_buildMetaChip(context, Icons.train, '${stats.length} had'),
|
||||
_buildMetaChip(
|
||||
context,
|
||||
Icons.emoji_events_outlined,
|
||||
'$winnerCount winners',
|
||||
),
|
||||
] else
|
||||
_buildMetaChip(context, Icons.train, 'No traction yet'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
@@ -301,7 +293,7 @@ class _TripsPageState extends State<TripsPage> {
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.train),
|
||||
label: const Text('Locos'),
|
||||
onPressed: () => _showTripWinners(context, trip),
|
||||
onPressed: () => _showTripWinners(context, trip, summary),
|
||||
),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
@@ -532,14 +524,19 @@ class _TripsPageState extends State<TripsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTripWinners(BuildContext context, TripDetail trip) {
|
||||
void _showTripWinners(
|
||||
BuildContext context,
|
||||
TripDetail trip,
|
||||
TripSummary? summary,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: FutureBuilder<List<TripLocoStat>>(
|
||||
future: _tripStatsFuture(trip.id),
|
||||
future: _loadTripStats(trip, summary),
|
||||
initialData: _cachedTripStats(trip, summary),
|
||||
builder: (ctx, snapshot) {
|
||||
final items = snapshot.data ?? [];
|
||||
final loading =
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
@@ -20,6 +22,35 @@ String _asString(dynamic value, [String fallback = '']) {
|
||||
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]) {
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
@@ -487,25 +518,45 @@ class TripSummary {
|
||||
final int tripId;
|
||||
final String tripName;
|
||||
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({
|
||||
required this.tripId,
|
||||
required this.tripName,
|
||||
required this.tripMileage,
|
||||
});
|
||||
this.legCount = 0,
|
||||
List<TripLocoStat>? locoStats,
|
||||
}) : locoStats = locoStats ?? const [];
|
||||
|
||||
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
|
||||
tripId: _asInt(json['trip_id']),
|
||||
tripName: _asString(json['trip_name']),
|
||||
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 {
|
||||
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? endTime;
|
||||
final DateTime? originTime;
|
||||
final DateTime? destinationTime;
|
||||
final double mileage;
|
||||
final int? beginDelayMinutes, endDelayMinutes;
|
||||
final List<Loco> locos;
|
||||
|
||||
Leg({
|
||||
@@ -523,27 +574,55 @@ class Leg {
|
||||
required this.driving,
|
||||
required this.user,
|
||||
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(
|
||||
id: _asInt(json['leg_id']),
|
||||
tripId: _asInt(json['leg_trip']),
|
||||
start: _asString(json['leg_start']),
|
||||
end: _asString(json['leg_end']),
|
||||
beginTime: _asDateTime(json['leg_begin_time']),
|
||||
timezone: _asInt(json['leg_timezone']),
|
||||
network: _asString(json['leg_network']),
|
||||
route: _asString(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(),
|
||||
);
|
||||
factory Leg.fromJson(Map<String, dynamic> json) {
|
||||
final endTimeRaw = json['leg_end_time'];
|
||||
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
|
||||
? null
|
||||
: _asDateTime(endTimeRaw);
|
||||
return Leg(
|
||||
id: _asInt(json['leg_id']),
|
||||
tripId: _asInt(json['leg_trip']),
|
||||
start: _asString(json['leg_start']),
|
||||
end: _asString(json['leg_end']),
|
||||
beginTime: _asDateTime(json['leg_begin_time']),
|
||||
endTime: parsedEndTime,
|
||||
originTime: json['leg_origin_time'] == null
|
||||
? null
|
||||
: _asDateTime(json['leg_origin_time']),
|
||||
destinationTime: json['leg_destination_time'] == null
|
||||
? null
|
||||
: _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 {
|
||||
@@ -625,17 +704,23 @@ class TripLeg {
|
||||
});
|
||||
|
||||
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
|
||||
id: json['leg_id'],
|
||||
start: json['leg_start'] ?? '',
|
||||
end: json['leg_end'] ?? '',
|
||||
id: _asInt(json['leg_id']),
|
||||
start: _asString(json['leg_start']),
|
||||
end: _asString(json['leg_end']),
|
||||
beginTime:
|
||||
json['leg_begin_time'] != null && json['leg_begin_time'] is String
|
||||
? DateTime.tryParse(json['leg_begin_time'])
|
||||
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
|
||||
network: json['leg_network'],
|
||||
route: json['leg_route'],
|
||||
network: _asString(json['leg_network'], ''),
|
||||
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(),
|
||||
notes: json['leg_notes'],
|
||||
notes: _asString(json['leg_notes'], ''),
|
||||
locos:
|
||||
(json['locos'] as List?)
|
||||
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
|
||||
@@ -649,21 +734,32 @@ class TripDetail {
|
||||
final String name;
|
||||
final double mileage;
|
||||
final int legCount;
|
||||
final List<TripLocoStat> locoStats;
|
||||
final List<TripLeg> legs;
|
||||
|
||||
int get locoHadCount => locoStats.length;
|
||||
int get winnersCount => locoStats.where((e) => e.won).length;
|
||||
|
||||
TripDetail({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.mileage,
|
||||
required this.legCount,
|
||||
required this.legs,
|
||||
});
|
||||
List<TripLocoStat>? locoStats,
|
||||
}) : locoStats = locoStats ?? const [];
|
||||
|
||||
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
|
||||
id: json['trip_id'] ?? json['id'] ?? 0,
|
||||
name: json['trip_name'] ?? '',
|
||||
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:
|
||||
(json['trip_legs'] as List?)
|
||||
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
|
||||
@@ -712,6 +808,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) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
@@ -827,3 +943,85 @@ class BadgeAward {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
part of 'data_service.dart';
|
||||
|
||||
extension DataServiceBadges on DataService {
|
||||
Future<void> fetchBadgeAwards() async {
|
||||
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');
|
||||
final json = await api.get(
|
||||
'/badge/awards/me?limit=$limit&offset=$offset&badge_code=$badgeCode',
|
||||
);
|
||||
List<dynamic>? list;
|
||||
if (json is List) {
|
||||
list = json;
|
||||
@@ -21,22 +29,102 @@ extension DataServiceBadges on DataService {
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.map(BadgeAward.fromJson)
|
||||
.toList();
|
||||
if (parsed != null) {
|
||||
parsed.sort((a, b) {
|
||||
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
|
||||
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
|
||||
return bTs.compareTo(aTs);
|
||||
});
|
||||
_badgeAwards = parsed;
|
||||
} else {
|
||||
_badgeAwards = [];
|
||||
}
|
||||
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');
|
||||
_badgeAwards = [];
|
||||
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,6 +21,8 @@ class DataService extends ChangeNotifier {
|
||||
|
||||
DataService({required this.api});
|
||||
|
||||
String? _currentUserId;
|
||||
|
||||
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
|
||||
|
||||
// Homepage Data
|
||||
@@ -102,6 +104,24 @@ class DataService extends ChangeNotifier {
|
||||
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 = [
|
||||
EventField(name: 'operator', display: 'Operator'),
|
||||
@@ -357,6 +377,8 @@ class DataService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_currentUserId = null;
|
||||
_lastLegsFetch = const _LegFetchOptions();
|
||||
_homepageStats = null;
|
||||
_legs = [];
|
||||
_onThisDay = [];
|
||||
@@ -367,9 +389,44 @@ class DataService extends ChangeNotifier {
|
||||
_isLocoTimelineLoading.clear();
|
||||
_latestLocoChanges = [];
|
||||
_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();
|
||||
}
|
||||
|
||||
void handleAuthChanged(String? userId) {
|
||||
if (_currentUserId == userId) return;
|
||||
_currentUserId = userId;
|
||||
clear();
|
||||
_currentUserId = userId;
|
||||
}
|
||||
|
||||
double getMileageForCurrentYear() {
|
||||
final currentYear = DateTime.now().year;
|
||||
return getMileageForYear(currentYear) ?? 0;
|
||||
|
||||
@@ -4,16 +4,25 @@ extension DataServiceTrips on DataService {
|
||||
Future<void> fetchTripDetails() async {
|
||||
_isTripDetailsLoading = true;
|
||||
try {
|
||||
final json = await api.get('/trips/legs-and-stats');
|
||||
if (json is List) {
|
||||
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
|
||||
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
|
||||
} else {
|
||||
_tripDetails = [];
|
||||
}
|
||||
final json = await api.get('/trips/info');
|
||||
final tripDetails = _parseTripInfoList(json);
|
||||
_tripDetails = [...tripDetails]..sort((a, b) => b.id.compareTo(a.id));
|
||||
_tripList = tripDetails
|
||||
.map(
|
||||
(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) {
|
||||
debugPrint('Failed to fetch trip_map: $e');
|
||||
_tripDetails = [];
|
||||
_tripList = [];
|
||||
} finally {
|
||||
_isTripDetailsLoading = false;
|
||||
_notifyAsync();
|
||||
@@ -23,48 +32,17 @@ extension DataServiceTrips on DataService {
|
||||
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
||||
try {
|
||||
final json = await api.get('/trips/stats/$tripId');
|
||||
return _parseTripLocoStats(json);
|
||||
return TripLocoStat.listFromJson(json);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch trip loco stats: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
|
||||
List<dynamic>? list;
|
||||
if (json is List) {
|
||||
list = json.expand((e) => e is List ? e : [e]).toList();
|
||||
} else if (json is Map) {
|
||||
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
|
||||
final candidate = json[key];
|
||||
if (candidate is List) {
|
||||
list = candidate.expand((e) => e is List ? e : [e]).toList();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (list == null) return [];
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => TripLocoStat.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> fetchTrips() async {
|
||||
try {
|
||||
final json = await api.get('/trips/mileage');
|
||||
Iterable<dynamic>? raw;
|
||||
if (json is List) {
|
||||
raw = json;
|
||||
} else if (json is Map) {
|
||||
for (final key in ['trips', 'trip_data', 'data']) {
|
||||
final value = json[key];
|
||||
if (value is List) {
|
||||
raw = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final json = await api.get('/trips/info');
|
||||
final raw = _extractTrips(json);
|
||||
if (raw != null) {
|
||||
final tripMap = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
@@ -119,8 +97,9 @@ extension DataServiceTrips on DataService {
|
||||
}
|
||||
|
||||
void upsertTripSummary(TripSummary trip) {
|
||||
final existingIndex =
|
||||
_tripList.indexWhere((element) => element.tripId == trip.tripId);
|
||||
final existingIndex = _tripList.indexWhere(
|
||||
(element) => element.tripId == trip.tripId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
_tripList[existingIndex] = trip;
|
||||
} else {
|
||||
@@ -129,4 +108,24 @@ extension DataServiceTrips on DataService {
|
||||
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mileograph_flutter/components/pages/calculator.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/loco_legs.dart';
|
||||
@@ -19,17 +21,28 @@ import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final GlobalKey<NavigatorState> _shellNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
const List<String> _contentPages = [
|
||||
"/dashboard",
|
||||
"/calculator",
|
||||
"/logbook",
|
||||
"/traction",
|
||||
"/add",
|
||||
"/more",
|
||||
];
|
||||
|
||||
const List<String> _defaultTabDestinations = [
|
||||
"/dashboard",
|
||||
"/calculator",
|
||||
"/logbook/entries",
|
||||
"/traction",
|
||||
"/add",
|
||||
"/more",
|
||||
];
|
||||
|
||||
const int _addTabIndex = 5;
|
||||
const int _addTabIndex = 4;
|
||||
|
||||
class _NavItem {
|
||||
final String label;
|
||||
@@ -39,6 +52,7 @@ class _NavItem {
|
||||
|
||||
const List<_NavItem> _navItems = [
|
||||
_NavItem("Home", Icons.home),
|
||||
_NavItem("Calculator", Icons.route),
|
||||
_NavItem("Logbook", Icons.menu_book),
|
||||
_NavItem("Traction", Icons.train),
|
||||
_NavItem("Add", Icons.add),
|
||||
@@ -53,10 +67,9 @@ int tabIndexForPath(String path) {
|
||||
matchPath = '/logbook/entries';
|
||||
} else if (matchPath.startsWith('/trips')) {
|
||||
matchPath = '/logbook/trips';
|
||||
} else if (matchPath == '/logbook') {
|
||||
matchPath = '/logbook/entries';
|
||||
} else if (matchPath.startsWith('/logbook/trips')) {
|
||||
matchPath = '/logbook/entries';
|
||||
}
|
||||
if (matchPath.startsWith('/logbook')) {
|
||||
matchPath = '/logbook';
|
||||
} else if (matchPath.startsWith('/profile') ||
|
||||
matchPath.startsWith('/settings') ||
|
||||
matchPath.startsWith('/more')) {
|
||||
@@ -105,10 +118,7 @@ class _MyAppState extends State<MyApp> {
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
redirect: (_, __) => '/dashboard',
|
||||
),
|
||||
GoRoute(path: '/', redirect: (context, state) => '/dashboard'),
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
builder: (context, state, child) => MyHomePage(child: child),
|
||||
@@ -117,9 +127,20 @@ class _MyAppState extends State<MyApp> {
|
||||
path: '/dashboard',
|
||||
builder: (context, state) => const Dashboard(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/calculator',
|
||||
builder: (context, state) => const CalculatorPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'details',
|
||||
builder: (context, state) =>
|
||||
CalculatorDetailsPage(result: state.extra),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/logbook',
|
||||
builder: (context, state) => const LogbookPage(),
|
||||
redirect: (context, state) => '/logbook/entries',
|
||||
),
|
||||
GoRoute(
|
||||
path: '/logbook/entries',
|
||||
@@ -132,12 +153,11 @@ class _MyAppState extends State<MyApp> {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/trips',
|
||||
builder: (context, state) =>
|
||||
const LogbookPage(initialTab: LogbookTab.trips),
|
||||
redirect: (context, state) => '/logbook/trips',
|
||||
),
|
||||
GoRoute(
|
||||
path: '/legs',
|
||||
builder: (context, state) => const LogbookPage(),
|
||||
redirect: (context, state) => '/logbook/entries',
|
||||
),
|
||||
GoRoute(
|
||||
path: '/traction',
|
||||
@@ -206,7 +226,10 @@ class _MyAppState extends State<MyApp> {
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
@@ -254,14 +277,14 @@ class MyHomePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
List<String> get contentPages => _contentPages;
|
||||
List<String> get tabDestinations => _defaultTabDestinations;
|
||||
|
||||
Future<void> _onItemTapped(int index, int currentIndex) async {
|
||||
if (index < 0 || index >= contentPages.length) {
|
||||
if (index < 0 || index >= tabDestinations.length) {
|
||||
return;
|
||||
}
|
||||
final currentPath = GoRouterState.of(context).uri.path;
|
||||
final targetPath = contentPages[index];
|
||||
final targetPath = tabDestinations[index];
|
||||
final alreadyAtTarget =
|
||||
currentPath == targetPath || currentPath.startsWith('$targetPath/');
|
||||
if (index == currentIndex && alreadyAtTarget) return;
|
||||
@@ -272,9 +295,9 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
});
|
||||
}
|
||||
|
||||
final List<int> _history = [];
|
||||
final List<String> _history = [];
|
||||
int _historyPosition = -1;
|
||||
final List<int> _forwardHistory = [];
|
||||
final List<String> _forwardHistory = [];
|
||||
bool _suppressRecord = false;
|
||||
|
||||
bool _fetched = false;
|
||||
@@ -323,7 +346,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final uri = GoRouterState.of(context).uri;
|
||||
final pageIndex = tabIndexForPath(uri.path);
|
||||
_syncHistory(pageIndex);
|
||||
_syncHistory(uri.path);
|
||||
if (pageIndex != _addTabIndex) {
|
||||
NavigationGuard.unregister();
|
||||
}
|
||||
@@ -367,7 +390,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
TextSpan(
|
||||
children: const [
|
||||
TextSpan(text: "Mile"),
|
||||
TextSpan(text: "O", style: TextStyle(color: Colors.red)),
|
||||
TextSpan(
|
||||
text: "O",
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
TextSpan(text: "graph"),
|
||||
],
|
||||
style: const TextStyle(
|
||||
@@ -384,7 +410,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
onPressed: () => context.go('/more/settings'),
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
|
||||
IconButton(
|
||||
onPressed: auth.logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: isWide
|
||||
@@ -442,7 +471,8 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
return Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.browserForward):
|
||||
const _ForwardIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
@@ -468,7 +498,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop) return;
|
||||
await _handleBackNavigation(allowExit: true, recordForward: false);
|
||||
await _handleBackNavigation(
|
||||
allowExit: true,
|
||||
recordForward: false,
|
||||
);
|
||||
},
|
||||
child: scaffold,
|
||||
),
|
||||
@@ -488,7 +521,9 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}
|
||||
|
||||
Widget _buildRailToggleButton(bool railExtended) {
|
||||
final collapseIcon = railExtended ? Icons.chevron_left : Icons.chevron_right;
|
||||
final collapseIcon = railExtended
|
||||
? Icons.chevron_left
|
||||
: Icons.chevron_right;
|
||||
final collapseLabel = railExtended ? 'Collapse' : 'Expand';
|
||||
|
||||
if (railExtended) {
|
||||
@@ -544,24 +579,21 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
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.
|
||||
}
|
||||
if (!mounted) return;
|
||||
final isWide = MediaQuery.of(context).size.width >= 900;
|
||||
|
||||
final panelBuilder = (BuildContext ctx) {
|
||||
return _buildNotificationsContent(ctx, isWide);
|
||||
};
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (isWide) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: panelBuilder(dialogCtx),
|
||||
child: _buildNotificationsContent(dialogCtx, isWide),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -569,11 +601,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetCtx) {
|
||||
final height = MediaQuery.of(context).size.height * 0.9;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
height: sheetHeight,
|
||||
child: SafeArea(
|
||||
child: panelBuilder(sheetCtx),
|
||||
child: _buildNotificationsContent(sheetCtx, isWide),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -585,8 +616,9 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
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;
|
||||
final listHeight = isWide
|
||||
? 380.0
|
||||
: MediaQuery.of(context).size.height * 0.6;
|
||||
|
||||
Widget body;
|
||||
if (loading && notifications.isEmpty) {
|
||||
@@ -606,7 +638,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
height: listHeight,
|
||||
child: ListView.separated(
|
||||
itemCount: notifications.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
separatorBuilder: (_, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = notifications[index];
|
||||
return Card(
|
||||
@@ -626,9 +658,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
item.title.isNotEmpty
|
||||
? item.title
|
||||
: 'Notification',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -640,15 +670,19 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_formatNotificationTime(item.createdAt!),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withOpacity(0.7),
|
||||
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,
|
||||
);
|
||||
}(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -657,10 +691,8 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => _dismissNotifications(
|
||||
context,
|
||||
[item.id],
|
||||
),
|
||||
onPressed: () =>
|
||||
_dismissNotifications(context, [item.id]),
|
||||
child: const Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
@@ -686,19 +718,18 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
children: [
|
||||
Text(
|
||||
'Notifications',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
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(),
|
||||
),
|
||||
context,
|
||||
notifications.map((e) => e.id).toList(),
|
||||
),
|
||||
child: const Text('Dismiss all'),
|
||||
),
|
||||
],
|
||||
@@ -720,9 +751,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
try {
|
||||
await context.read<DataService>().dismissNotifications(ids);
|
||||
} catch (e) {
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Failed to dismiss: $e')),
|
||||
);
|
||||
messenger?.showSnackBar(SnackBar(content: Text('Failed to dismiss: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,9 +771,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
color: Colors.redAccent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 20),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
@@ -757,31 +784,32 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path);
|
||||
|
||||
Future<bool> _handleBackNavigation({
|
||||
bool allowExit = false,
|
||||
bool recordForward = false,
|
||||
}) async {
|
||||
final pageIndex = _currentPageIndex;
|
||||
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(pageIndex);
|
||||
if (recordForward) _pushForward(currentPath);
|
||||
_historyPosition -= 1;
|
||||
_suppressRecord = true;
|
||||
context.go(contentPages[_history[_historyPosition]]);
|
||||
context.go(_history[_historyPosition]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pageIndex != 0) {
|
||||
if (recordForward) _pushForward(pageIndex);
|
||||
final homePath = tabDestinations.first;
|
||||
if (currentPath != homePath) {
|
||||
if (recordForward) _pushForward(currentPath);
|
||||
_suppressRecord = true;
|
||||
context.go(contentPages[0]);
|
||||
context.go(homePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -795,35 +823,48 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
Future<bool> _handleForwardNavigation() async {
|
||||
if (_forwardHistory.isEmpty) return false;
|
||||
final nextTab = _forwardHistory.removeLast();
|
||||
final nextPath = _forwardHistory.removeLast();
|
||||
|
||||
// Move cursor forward, keeping history in sync.
|
||||
if (_historyPosition < _history.length - 1) {
|
||||
_historyPosition += 1;
|
||||
_history[_historyPosition] = nextTab;
|
||||
_history[_historyPosition] = nextPath;
|
||||
if (_historyPosition < _history.length - 1) {
|
||||
_history.removeRange(_historyPosition + 1, _history.length);
|
||||
}
|
||||
} else {
|
||||
_history.add(nextTab);
|
||||
_history.add(nextPath);
|
||||
_historyPosition = _history.length - 1;
|
||||
}
|
||||
|
||||
_suppressRecord = true;
|
||||
if (!mounted) return false;
|
||||
context.go(contentPages[nextTab]);
|
||||
context.go(nextPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _pushForward(int pageIndex) {
|
||||
if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) {
|
||||
_forwardHistory.add(pageIndex);
|
||||
void _pushForward(String path) {
|
||||
if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
|
||||
_forwardHistory.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncHistory(int pageIndex) {
|
||||
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(pageIndex);
|
||||
_history.add(path);
|
||||
_historyPosition = 0;
|
||||
return;
|
||||
}
|
||||
@@ -833,13 +874,13 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}
|
||||
if (_historyPosition >= 0 &&
|
||||
_historyPosition < _history.length &&
|
||||
_history[_historyPosition] == pageIndex) {
|
||||
_history[_historyPosition] == path) {
|
||||
return;
|
||||
}
|
||||
if (_historyPosition < _history.length - 1) {
|
||||
_history.removeRange(_historyPosition + 1, _history.length);
|
||||
}
|
||||
_history.add(pageIndex);
|
||||
_history.add(path);
|
||||
_historyPosition = _history.length - 1;
|
||||
_forwardHistory.clear();
|
||||
}
|
||||
@@ -847,6 +888,6 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
void _navigateToIndex(int index) {
|
||||
_suppressRecord = false;
|
||||
_forwardHistory.clear();
|
||||
context.go(contentPages[index]);
|
||||
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
|
||||
# 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.
|
||||
version: 0.4.0+1
|
||||
version: 0.5.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
@@ -8,13 +8,12 @@ void main() {
|
||||
expect(tabIndexForPath('/calculator/details'), 1);
|
||||
expect(tabIndexForPath('/legs'), 2);
|
||||
expect(tabIndexForPath('/traction/12/timeline'), 3);
|
||||
expect(tabIndexForPath('/trips'), 4);
|
||||
expect(tabIndexForPath('/add'), 5);
|
||||
expect(tabIndexForPath('/trips'), 2);
|
||||
expect(tabIndexForPath('/add'), 4);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user