QoL changes
All checks were successful
All checks were successful
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,3 +43,5 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
api_return_examples.txt
|
||||||
@@ -7,15 +7,24 @@ import 'package:mileograph_flutter/services/authservice.dart';
|
|||||||
import 'package:mileograph_flutter/services/dataService.dart';
|
import 'package:mileograph_flutter/services/dataService.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class Dashboard extends StatelessWidget {
|
class Dashboard extends StatefulWidget {
|
||||||
const Dashboard({super.key});
|
const Dashboard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Dashboard> createState() => _DashboardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardState extends State<Dashboard> {
|
||||||
|
bool _showAllOnThisDay = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final auth = context.watch<AuthService>();
|
final auth = context.watch<AuthService>();
|
||||||
final stats = data.homepageStats;
|
final stats = data.homepageStats;
|
||||||
|
|
||||||
|
final isInitialLoading = data.isHomepageLoading || stats == null;
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await data.fetchHomepageStats();
|
await data.fetchHomepageStats();
|
||||||
@@ -34,32 +43,53 @@ class Dashboard extends StatelessWidget {
|
|||||||
currentYearMileage: data.getMileageForCurrentYear(),
|
currentYearMileage: data.getMileageForCurrentYear(),
|
||||||
trips: data.trips.length,
|
trips: data.trips.length,
|
||||||
);
|
);
|
||||||
return ListView(
|
return Stack(
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
ListView(
|
||||||
const SizedBox(height: 12),
|
padding: const EdgeInsets.all(16),
|
||||||
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
children: [
|
||||||
const SizedBox(height: 16),
|
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
||||||
isWide
|
const SizedBox(height: 12),
|
||||||
? Row(
|
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const SizedBox(height: 16),
|
||||||
children: [
|
isWide
|
||||||
Expanded(child: _buildMainColumn(context, data)),
|
? Row(
|
||||||
const SizedBox(width: 16),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
SizedBox(
|
children: [
|
||||||
width: 360,
|
Expanded(child: _buildMainColumn(context, data)),
|
||||||
child: _buildSidebar(context, data),
|
const SizedBox(width: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: _buildSidebar(context, data),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
_buildMainColumn(context, data),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildSidebar(context, data),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
: Column(
|
if (isInitialLoading)
|
||||||
children: [
|
Positioned.fill(
|
||||||
_buildMainColumn(context, data),
|
child: Container(
|
||||||
const SizedBox(height: 16),
|
color:
|
||||||
_buildSidebar(context, data),
|
Theme.of(context).colorScheme.surface.withOpacity(0.7),
|
||||||
],
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text('Loading dashboard data...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -153,6 +183,17 @@ class Dashboard extends StatelessWidget {
|
|||||||
_buildCard(
|
_buildCard(
|
||||||
context,
|
context,
|
||||||
title: 'On this day',
|
title: 'On this day',
|
||||||
|
action: data.onThisDay
|
||||||
|
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
||||||
|
.length >
|
||||||
|
5
|
||||||
|
? TextButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_showAllOnThisDay = !_showAllOnThisDay;
|
||||||
|
}),
|
||||||
|
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
trailing: data.isOnThisDayLoading
|
trailing: data.isOnThisDayLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 18,
|
height: 18,
|
||||||
@@ -163,6 +204,7 @@ class Dashboard extends StatelessWidget {
|
|||||||
child: _buildLegList(
|
child: _buildLegList(
|
||||||
context,
|
context,
|
||||||
data.onThisDay,
|
data.onThisDay,
|
||||||
|
showAll: _showAllOnThisDay,
|
||||||
emptyMessage: 'No historical moves for today yet.',
|
emptyMessage: 'No historical moves for today yet.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -231,12 +273,17 @@ class Dashboard extends StatelessWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<Leg> legs, {
|
List<Leg> legs, {
|
||||||
required String emptyMessage,
|
required String emptyMessage,
|
||||||
|
bool showAll = false,
|
||||||
}) {
|
}) {
|
||||||
if (legs.isEmpty) {
|
final filtered = legs
|
||||||
|
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
||||||
|
.toList();
|
||||||
|
if (filtered.isEmpty) {
|
||||||
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
|
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
|
||||||
}
|
}
|
||||||
|
final toShow = showAll ? filtered : filtered.take(5).toList();
|
||||||
return Column(
|
return Column(
|
||||||
children: legs.take(5).map((leg) {
|
children: toShow.map((leg) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/dataService.dart';
|
import 'package:mileograph_flutter/services/dataService.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -155,25 +158,6 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
SegmentedButton<int>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: 0,
|
|
||||||
icon: Icon(Icons.south),
|
|
||||||
label: Text('Newest first'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: 1,
|
|
||||||
icon: Icon(Icons.north),
|
|
||||||
label: Text('Oldest first'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_sortDirection},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
setState(() => _sortDirection = selection.first);
|
|
||||||
_refreshLegs();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _pickDate(start: true),
|
onPressed: () => _pickDate(start: true),
|
||||||
icon: const Icon(Icons.calendar_month),
|
icon: const Icon(Icons.calendar_month),
|
||||||
@@ -229,37 +213,7 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
else
|
else
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
...legs.map((leg) => Card(
|
...legs.map((leg) => _buildLegCard(context, leg)),
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.train),
|
|
||||||
title: Text('${leg.start} → ${leg.end}'),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(_formatDateTime(leg.beginTime)),
|
|
||||||
if (leg.headcode.isNotEmpty)
|
|
||||||
Text('Headcode: ${leg.headcode}'),
|
|
||||||
if (leg.route.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
leg.route,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('${leg.mileage.toStringAsFixed(1)} mi'),
|
|
||||||
Text(
|
|
||||||
leg.network,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (data.legsHasMore || data.isLegsLoading)
|
if (data.legsHasMore || data.isLegsLoading)
|
||||||
Align(
|
Align(
|
||||||
@@ -297,4 +251,148 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||||
return '$dateStr · $timeStr';
|
return '$dateStr · $timeStr';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLegCard(BuildContext context, Leg leg) {
|
||||||
|
final routeSegments = _parseRouteSegments(leg.route);
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
return Card(
|
||||||
|
child: ExpansionTile(
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
leading: const Icon(Icons.train),
|
||||||
|
title: Text('${leg.start} → ${leg.end}'),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(_formatDateTime(leg.beginTime)),
|
||||||
|
if (leg.headcode.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'Headcode: ${leg.headcode}',
|
||||||
|
style: textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
if (leg.network.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
leg.network,
|
||||||
|
style: textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${leg.mileage.toStringAsFixed(1)} mi',
|
||||||
|
style:
|
||||||
|
textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
if (leg.tripId != 0)
|
||||||
|
Text(
|
||||||
|
'Trip #${leg.tripId}',
|
||||||
|
style: textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (leg.notes.isNotEmpty) ...[
|
||||||
|
Text('Notes', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(leg.notes),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (leg.locos.isNotEmpty) ...[
|
||||||
|
Text('Locos', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _buildLocoChips(context, leg),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (routeSegments.isNotEmpty) ...[
|
||||||
|
Text('Route', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildRouteList(routeSegments),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return leg.locos
|
||||||
|
.map(
|
||||||
|
(loco) => Chip(
|
||||||
|
label: Text('${loco.locoClass} ${loco.number}'),
|
||||||
|
avatar: const Icon(Icons.directions_railway, size: 16),
|
||||||
|
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRouteList(List<String> segments) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: segments
|
||||||
|
.map(
|
||||||
|
(segment) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.circle, size: 10),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(segment)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseRouteSegments(String route) {
|
||||||
|
final trimmed = route.trim();
|
||||||
|
if (trimmed.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(trimmed);
|
||||||
|
if (decoded is List) {
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore and try alternative parsing
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
final replaced = trimmed.replaceAll("'", '"');
|
||||||
|
final decoded = jsonDecode(replaced);
|
||||||
|
if (decoded is List) {
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (trimmed.contains('->')) {
|
||||||
|
return trimmed
|
||||||
|
.split('->')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (trimmed.contains(',')) {
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [trimmed];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -8,6 +9,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
|
|||||||
import 'package:mileograph_flutter/services/apiService.dart';
|
import 'package:mileograph_flutter/services/apiService.dart';
|
||||||
import 'package:mileograph_flutter/services/dataService.dart';
|
import 'package:mileograph_flutter/services/dataService.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class NewEntryPage extends StatefulWidget {
|
class NewEntryPage extends StatefulWidget {
|
||||||
const NewEntryPage({super.key});
|
const NewEntryPage({super.key});
|
||||||
@@ -17,6 +19,8 @@ class NewEntryPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NewEntryPageState extends State<NewEntryPage> {
|
class _NewEntryPageState extends State<NewEntryPage> {
|
||||||
|
static const _draftPrefsKey = 'new_entry_draft';
|
||||||
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
TimeOfDay _selectedTime = TimeOfDay.now();
|
TimeOfDay _selectedTime = TimeOfDay.now();
|
||||||
@@ -31,20 +35,42 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
RouteResult? _routeResult;
|
RouteResult? _routeResult;
|
||||||
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
||||||
int? _selectedTripId;
|
int? _selectedTripId;
|
||||||
|
bool _restoringDraft = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
for (final controller in [
|
||||||
|
_startController,
|
||||||
|
_endController,
|
||||||
|
_headcodeController,
|
||||||
|
_notesController,
|
||||||
|
_mileageController,
|
||||||
|
_networkController,
|
||||||
|
]) {
|
||||||
|
controller.addListener(_saveDraft);
|
||||||
|
}
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
data.fetchClassList();
|
data.fetchClassList();
|
||||||
data.fetchTrips();
|
data.fetchTrips();
|
||||||
|
_loadDraft();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final controller in [
|
||||||
|
_startController,
|
||||||
|
_endController,
|
||||||
|
_headcodeController,
|
||||||
|
_notesController,
|
||||||
|
_mileageController,
|
||||||
|
_networkController,
|
||||||
|
]) {
|
||||||
|
controller.removeListener(_saveDraft);
|
||||||
|
}
|
||||||
_startController.dispose();
|
_startController.dispose();
|
||||||
_endController.dispose();
|
_endController.dispose();
|
||||||
_headcodeController.dispose();
|
_headcodeController.dispose();
|
||||||
@@ -73,7 +99,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
DropdownMenuItem(value: t.tripId, child: Text(t.tripName)),
|
DropdownMenuItem(value: t.tripId, child: Text(t.tripName)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (val) => setState(() => _selectedTripId = val),
|
onChanged: (val) {
|
||||||
|
setState(() => _selectedTripId = val);
|
||||||
|
_saveDraft();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -126,6 +155,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
: TripSummary(tripId: 0, tripName: result, tripMileage: 0),
|
: TripSummary(tripId: 0, tripName: result, tripMileage: 0),
|
||||||
);
|
);
|
||||||
setState(() => _selectedTripId = match.tripId);
|
setState(() => _selectedTripId = match.tripId);
|
||||||
|
_saveDraft();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
@@ -149,6 +179,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_mileageController.text = result.distance.toStringAsFixed(2);
|
_mileageController.text = result.distance.toStringAsFixed(2);
|
||||||
_useManualMileage = false;
|
_useManualMileage = false;
|
||||||
});
|
});
|
||||||
|
_saveDraft();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +214,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_saveDraft();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -197,6 +229,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
);
|
);
|
||||||
if (picked != null) setState(() => _selectedDate = picked);
|
if (picked != null) setState(() => _selectedDate = picked);
|
||||||
|
_saveDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickTime() async {
|
Future<void> _pickTime() async {
|
||||||
@@ -204,7 +237,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
initialTime: _selectedTime,
|
initialTime: _selectedTime,
|
||||||
);
|
);
|
||||||
if (picked != null) setState(() => _selectedTime = picked);
|
if (picked != null) {
|
||||||
|
setState(() => _selectedTime = picked);
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime get _legDateTime => DateTime(
|
DateTime get _legDateTime => DateTime(
|
||||||
@@ -295,7 +331,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('Entry submitted')));
|
).showSnackBar(const SnackBar(content: Text('Entry submitted')));
|
||||||
_formKey.currentState!.reset();
|
_resetFormState(clearDraft: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
@@ -306,6 +342,166 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _resetFormState({bool clearDraft = false}) async {
|
||||||
|
_formKey.currentState?.reset();
|
||||||
|
_startController.clear();
|
||||||
|
_endController.clear();
|
||||||
|
_headcodeController.clear();
|
||||||
|
_notesController.clear();
|
||||||
|
_mileageController.clear();
|
||||||
|
_networkController.clear();
|
||||||
|
setState(() {
|
||||||
|
_selectedDate = DateTime.now();
|
||||||
|
_selectedTime = TimeOfDay.now();
|
||||||
|
_useManualMileage = false;
|
||||||
|
_routeResult = null;
|
||||||
|
_tractionItems
|
||||||
|
..clear()
|
||||||
|
..add(_TractionItem.marker());
|
||||||
|
_selectedTripId = null;
|
||||||
|
_submitting = false;
|
||||||
|
});
|
||||||
|
if (clearDraft) {
|
||||||
|
await _clearDraft();
|
||||||
|
} else {
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveDraft() async {
|
||||||
|
if (_restoringDraft) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final draft = {
|
||||||
|
"date": _selectedDate.toIso8601String(),
|
||||||
|
"time": {"hour": _selectedTime.hour, "minute": _selectedTime.minute},
|
||||||
|
"start": _startController.text,
|
||||||
|
"end": _endController.text,
|
||||||
|
"headcode": _headcodeController.text,
|
||||||
|
"notes": _notesController.text,
|
||||||
|
"mileage": _mileageController.text,
|
||||||
|
"network": _networkController.text,
|
||||||
|
"useManualMileage": _useManualMileage,
|
||||||
|
"selectedTripId": _selectedTripId,
|
||||||
|
"routeResult": _routeResult == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
"input_route": _routeResult!.inputRoute,
|
||||||
|
"calculated_route": _routeResult!.calculatedRoute,
|
||||||
|
"costs": _routeResult!.costs,
|
||||||
|
"distance": _routeResult!.distance,
|
||||||
|
},
|
||||||
|
"tractionItems": _serializeTractionItems(),
|
||||||
|
};
|
||||||
|
await prefs.setString(_draftPrefsKey, jsonEncode(draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearDraft() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_draftPrefsKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDraft() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_draftPrefsKey);
|
||||||
|
if (raw == null) return;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(raw);
|
||||||
|
if (data is! Map) return;
|
||||||
|
_restoringDraft = true;
|
||||||
|
setState(() {
|
||||||
|
if (data['date'] is String) {
|
||||||
|
_selectedDate = DateTime.tryParse(data['date']) ?? _selectedDate;
|
||||||
|
}
|
||||||
|
if (data['time'] is Map) {
|
||||||
|
final time = data['time'] as Map;
|
||||||
|
final hour = time['hour'] as int?;
|
||||||
|
final minute = time['minute'] as int?;
|
||||||
|
if (hour != null && minute != null) {
|
||||||
|
_selectedTime = TimeOfDay(hour: hour, minute: minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_useManualMileage = data['useManualMileage'] ?? _useManualMileage;
|
||||||
|
_selectedTripId = data['selectedTripId'];
|
||||||
|
if (data['routeResult'] is Map<String, dynamic>) {
|
||||||
|
_routeResult =
|
||||||
|
RouteResult.fromJson(Map<String, dynamic>.from(data['routeResult']));
|
||||||
|
_mileageController.text = _routeResult!.distance.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
if (data['tractionItems'] is List) {
|
||||||
|
_restoreTractionItems(List<Map<String, dynamic>>.from(
|
||||||
|
data['tractionItems'].cast<Map>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_startController.text = data['start'] ?? '';
|
||||||
|
_endController.text = data['end'] ?? '';
|
||||||
|
_headcodeController.text = data['headcode'] ?? '';
|
||||||
|
_notesController.text = data['notes'] ?? '';
|
||||||
|
_mileageController.text = data['mileage'] ?? '';
|
||||||
|
_networkController.text = data['network'] ?? '';
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore corrupt draft data
|
||||||
|
} finally {
|
||||||
|
_restoringDraft = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _serializeTractionItems() {
|
||||||
|
return _tractionItems
|
||||||
|
.map(
|
||||||
|
(item) => {
|
||||||
|
"isMarker": item.isMarker,
|
||||||
|
"powering": item.powering,
|
||||||
|
"loco": item.loco == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
"id": item.loco!.id,
|
||||||
|
"type": item.loco!.type,
|
||||||
|
"number": item.loco!.number,
|
||||||
|
"class": item.loco!.locoClass,
|
||||||
|
"name": item.loco!.name,
|
||||||
|
"operator": item.loco!.operator,
|
||||||
|
"notes": item.loco!.notes,
|
||||||
|
"evn": item.loco!.evn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreTractionItems(List<Map<String, dynamic>> items) {
|
||||||
|
final restored = <_TractionItem>[];
|
||||||
|
for (final item in items) {
|
||||||
|
final locoData = item['loco'] as Map<String, dynamic>?;
|
||||||
|
LocoSummary? loco;
|
||||||
|
if (locoData != null) {
|
||||||
|
loco = LocoSummary(
|
||||||
|
locoId: locoData['id'] ?? 0,
|
||||||
|
locoType: locoData['type'] ?? '',
|
||||||
|
locoNumber: locoData['number'] ?? '',
|
||||||
|
locoName: locoData['name'] ?? '',
|
||||||
|
locoClass: locoData['class'] ?? '',
|
||||||
|
locoOperator: locoData['operator'] ?? '',
|
||||||
|
locoNotes: locoData['notes'],
|
||||||
|
locoEvn: locoData['evn'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
restored.add(
|
||||||
|
_TractionItem(
|
||||||
|
loco: loco,
|
||||||
|
powering: item['powering'] ?? true,
|
||||||
|
isMarker: item['isMarker'] ?? false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (restored.where((e) => e.isMarker).isEmpty) {
|
||||||
|
restored.insert(0, _TractionItem.marker());
|
||||||
|
}
|
||||||
|
_tractionItems
|
||||||
|
..clear()
|
||||||
|
..addAll(restored);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||||
@@ -413,6 +609,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_useManualMileage = val;
|
_useManualMileage = val;
|
||||||
});
|
});
|
||||||
|
_saveDraft();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_useManualMileage)
|
if (_useManualMileage)
|
||||||
@@ -506,6 +703,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final item = _tractionItems.removeAt(oldIndex);
|
final item = _tractionItems.removeAt(oldIndex);
|
||||||
_tractionItems.insert(newIndex, item);
|
_tractionItems.insert(newIndex, item);
|
||||||
});
|
});
|
||||||
|
_saveDraft();
|
||||||
},
|
},
|
||||||
itemCount: _tractionItems.length,
|
itemCount: _tractionItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -549,6 +747,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tractionItems[index] = item.copyWith(powering: v);
|
_tractionItems[index] = item.copyWith(powering: v);
|
||||||
});
|
});
|
||||||
|
_saveDraft();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -557,6 +756,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tractionItems.removeAt(index);
|
_tractionItems.removeAt(index);
|
||||||
});
|
});
|
||||||
|
_saveDraft();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,22 +23,15 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
final _classController = TextEditingController();
|
final _classController = TextEditingController();
|
||||||
final _classFocusNode = FocusNode();
|
final _classFocusNode = FocusNode();
|
||||||
final _numberController = TextEditingController();
|
final _numberController = TextEditingController();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
bool _mileageFirst = true;
|
bool _mileageFirst = true;
|
||||||
bool _initialised = false;
|
bool _initialised = false;
|
||||||
bool _showAdvancedFilters = false;
|
bool _showAdvancedFilters = false;
|
||||||
String? _selectedClass;
|
String? _selectedClass;
|
||||||
late Set<String> _selectedKeys;
|
late Set<String> _selectedKeys;
|
||||||
final _nameController = TextEditingController();
|
|
||||||
final _operatorController = TextEditingController();
|
|
||||||
final _statusController = TextEditingController();
|
|
||||||
final _evnController = TextEditingController();
|
|
||||||
final _ownerController = TextEditingController();
|
|
||||||
final _locationController = TextEditingController();
|
|
||||||
final _liveryController = TextEditingController();
|
|
||||||
final _domainController = TextEditingController();
|
|
||||||
final _typeController = TextEditingController();
|
|
||||||
|
|
||||||
int offset = 0;
|
final Map<String, TextEditingController> _dynamicControllers = {};
|
||||||
|
final Map<String, String?> _enumSelections = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -53,7 +46,9 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
_initialised = true;
|
_initialised = true;
|
||||||
_selectedKeys = {...widget.selectedKeys};
|
_selectedKeys = {...widget.selectedKeys};
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<DataService>().fetchClassList();
|
final data = context.read<DataService>();
|
||||||
|
data.fetchClassList();
|
||||||
|
data.fetchEventFields();
|
||||||
_refreshTraction();
|
_refreshTraction();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,47 +61,41 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
_classFocusNode.dispose();
|
_classFocusNode.dispose();
|
||||||
_numberController.dispose();
|
_numberController.dispose();
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_operatorController.dispose();
|
for (final controller in _dynamicControllers.values) {
|
||||||
_statusController.dispose();
|
controller.dispose();
|
||||||
_evnController.dispose();
|
}
|
||||||
_ownerController.dispose();
|
|
||||||
_locationController.dispose();
|
|
||||||
_liveryController.dispose();
|
|
||||||
_domainController.dispose();
|
|
||||||
_typeController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasFilters {
|
bool get _hasFilters {
|
||||||
|
final dynamicFieldsUsed = _dynamicControllers.values
|
||||||
|
.any((controller) => controller.text.trim().isNotEmpty) ||
|
||||||
|
_enumSelections.values
|
||||||
|
.any((value) => (value ?? '').toString().trim().isNotEmpty);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
_selectedClass,
|
_selectedClass,
|
||||||
_classController.text,
|
_classController.text,
|
||||||
_numberController.text,
|
_numberController.text,
|
||||||
_nameController.text,
|
_nameController.text,
|
||||||
_operatorController.text,
|
].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
|
||||||
_statusController.text,
|
dynamicFieldsUsed;
|
||||||
_evnController.text,
|
|
||||||
_ownerController.text,
|
|
||||||
_locationController.text,
|
|
||||||
_liveryController.text,
|
|
||||||
_domainController.text,
|
|
||||||
_typeController.text,
|
|
||||||
].any((value) => (value ?? '').toString().trim().isNotEmpty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshTraction({bool append = false}) async {
|
Future<void> _refreshTraction({bool append = false}) async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final filters = {
|
final filters = <String, dynamic>{};
|
||||||
"name": _nameController.text.trim(),
|
final name = _nameController.text.trim();
|
||||||
"operator": _operatorController.text.trim(),
|
if (name.isNotEmpty) filters['name'] = name;
|
||||||
"status": _statusController.text.trim(),
|
_dynamicControllers.forEach((key, controller) {
|
||||||
"evn": _evnController.text.trim(),
|
final value = controller.text.trim();
|
||||||
"owner": _ownerController.text.trim(),
|
if (value.isNotEmpty) filters[key] = value;
|
||||||
"location": _locationController.text.trim(),
|
});
|
||||||
"livery": _liveryController.text.trim(),
|
_enumSelections.forEach((key, value) {
|
||||||
"domain": _domainController.text.trim(),
|
if (value != null && value.toString().trim().isNotEmpty) {
|
||||||
"type": _typeController.text.trim(),
|
filters[key] = value;
|
||||||
}..removeWhere((key, value) => value.isEmpty);
|
}
|
||||||
|
});
|
||||||
final hadOnly = !_hasFilters;
|
final hadOnly = !_hasFilters;
|
||||||
await data.fetchTraction(
|
await data.fetchTraction(
|
||||||
hadOnly: hadOnly,
|
hadOnly: hadOnly,
|
||||||
@@ -120,21 +109,13 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _clearFilters() {
|
void _clearFilters() {
|
||||||
for (final controller in [
|
for (final controller in [_classController, _numberController, _nameController]) {
|
||||||
_classController,
|
|
||||||
_numberController,
|
|
||||||
_nameController,
|
|
||||||
_operatorController,
|
|
||||||
_statusController,
|
|
||||||
_evnController,
|
|
||||||
_ownerController,
|
|
||||||
_locationController,
|
|
||||||
_liveryController,
|
|
||||||
_domainController,
|
|
||||||
_typeController,
|
|
||||||
]) {
|
|
||||||
controller.clear();
|
controller.clear();
|
||||||
}
|
}
|
||||||
|
for (final controller in _dynamicControllers.values) {
|
||||||
|
controller.clear();
|
||||||
|
}
|
||||||
|
_enumSelections.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedClass = null;
|
_selectedClass = null;
|
||||||
_mileageFirst = true;
|
_mileageFirst = true;
|
||||||
@@ -151,12 +132,34 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<EventField> _activeEventFields(List<EventField> fields) {
|
||||||
|
return fields
|
||||||
|
.where(
|
||||||
|
(field) =>
|
||||||
|
!['class', 'number', 'name', 'build date', 'build_date']
|
||||||
|
.contains(field.name.toLowerCase()),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ensureControllersForFields(List<EventField> fields) {
|
||||||
|
for (final field in fields) {
|
||||||
|
if (field.enumValues != null) {
|
||||||
|
_enumSelections.putIfAbsent(field.name, () => null);
|
||||||
|
} else {
|
||||||
|
_dynamicControllers.putIfAbsent(field.name, () => TextEditingController());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final traction = data.traction;
|
final traction = data.traction;
|
||||||
final classOptions = data.locoClasses;
|
final classOptions = data.locoClasses;
|
||||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||||
|
_ensureControllersForFields(data.eventFields);
|
||||||
|
final extraFields = _activeEventFields(data.eventFields);
|
||||||
|
|
||||||
final listView = RefreshIndicator(
|
final listView = RefreshIndicator(
|
||||||
onRefresh: _refreshTraction,
|
onRefresh: _refreshTraction,
|
||||||
@@ -225,22 +228,17 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
fieldViewBuilder:
|
fieldViewBuilder:
|
||||||
(
|
(context, controller, focusNode, onFieldSubmitted) {
|
||||||
context,
|
return TextField(
|
||||||
controller,
|
controller: controller,
|
||||||
focusNode,
|
focusNode: focusNode,
|
||||||
onFieldSubmitted,
|
decoration: const InputDecoration(
|
||||||
) {
|
labelText: 'Class',
|
||||||
return TextField(
|
border: OutlineInputBorder(),
|
||||||
controller: controller,
|
),
|
||||||
focusNode: focusNode,
|
onSubmitted: (_) => _refreshTraction(),
|
||||||
decoration: const InputDecoration(
|
);
|
||||||
labelText: 'Class',
|
},
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
optionsViewBuilder: (context, onSelected, options) {
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
final optionList = options.toList();
|
final optionList = options.toList();
|
||||||
if (optionList.isEmpty) {
|
if (optionList.isEmpty) {
|
||||||
@@ -325,9 +323,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
: Icons.expand_more,
|
: Icons.expand_more,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
_showAdvancedFilters
|
_showAdvancedFilters ? 'Hide filters' : 'More filters',
|
||||||
? 'Hide filters'
|
|
||||||
: 'More filters',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
@@ -344,100 +340,28 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
firstChild: Padding(
|
firstChild: Padding(
|
||||||
padding: const EdgeInsets.only(top: 12.0),
|
padding: const EdgeInsets.only(top: 12.0),
|
||||||
child: Wrap(
|
child: data.isEventFieldsLoading
|
||||||
spacing: 12,
|
? const Center(
|
||||||
runSpacing: 12,
|
child: Padding(
|
||||||
children: [
|
padding: EdgeInsets.all(8.0),
|
||||||
SizedBox(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
width: isMobile ? double.infinity : 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _operatorController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Operator',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
)
|
||||||
),
|
: extraFields.isEmpty
|
||||||
),
|
? const Text('No extra filters available right now.')
|
||||||
SizedBox(
|
: Wrap(
|
||||||
width: isMobile ? double.infinity : 220,
|
spacing: 12,
|
||||||
child: TextField(
|
runSpacing: 12,
|
||||||
controller: _statusController,
|
children: extraFields
|
||||||
decoration: const InputDecoration(
|
.map(
|
||||||
labelText: 'Status',
|
(field) => _buildFilterInput(
|
||||||
border: OutlineInputBorder(),
|
context,
|
||||||
),
|
field,
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
isMobile,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
SizedBox(
|
.toList(),
|
||||||
width: isMobile ? double.infinity : 220,
|
),
|
||||||
child: TextField(
|
|
||||||
controller: _evnController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'EVN',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: isMobile ? double.infinity : 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _ownerController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Owner',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: isMobile ? double.infinity : 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _locationController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Location',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: isMobile ? double.infinity : 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _liveryController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Livery',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: isMobile ? double.infinity : 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _domainController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Domain',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: isMobile ? double.infinity : 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _typeController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Type',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _refreshTraction(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
secondChild: const SizedBox.shrink(),
|
secondChild: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
@@ -480,9 +404,8 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: data.isTractionLoading
|
onPressed:
|
||||||
? null
|
data.isTractionLoading ? null : () => _refreshTraction(append: true),
|
||||||
: () => _refreshTraction(append: true),
|
|
||||||
icon: data.isTractionLoading
|
icon: data.isTractionLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -535,6 +458,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
final status = loco.status ?? 'Unknown';
|
final status = loco.status ?? 'Unknown';
|
||||||
final operatorName = loco.operator ?? '';
|
final operatorName = loco.operator ?? '';
|
||||||
final domain = loco.domain ?? '';
|
final domain = loco.domain ?? '';
|
||||||
|
final statusColors = _statusChipColors(context, status);
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
@@ -564,9 +488,8 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
),
|
),
|
||||||
Chip(
|
Chip(
|
||||||
label: Text(status),
|
label: Text(status),
|
||||||
backgroundColor: Theme.of(
|
backgroundColor: statusColors.$1,
|
||||||
context,
|
labelStyle: TextStyle(color: statusColors.$2),
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -654,6 +577,44 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(Color, Color) _statusChipColors(BuildContext context, String status) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = scheme.brightness == Brightness.dark;
|
||||||
|
Color blend(Color base, {double bgOpacity = 0.18, double fgOpacity = 0.82}) {
|
||||||
|
final bg = Color.alphaBlend(
|
||||||
|
base.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity),
|
||||||
|
scheme.surface,
|
||||||
|
);
|
||||||
|
final fg = Color.alphaBlend(
|
||||||
|
base.withOpacity(isDark ? fgOpacity : fgOpacity * 0.8),
|
||||||
|
scheme.onSurface,
|
||||||
|
);
|
||||||
|
return Color.lerp(bg, fg, 0.0) ?? bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color background;
|
||||||
|
Color foreground;
|
||||||
|
final key = status.toLowerCase();
|
||||||
|
|
||||||
|
if (key.contains('scrap')) {
|
||||||
|
background = blend(Colors.red);
|
||||||
|
foreground = Colors.red.shade200.withOpacity(isDark ? 0.85 : 0.9);
|
||||||
|
} else if (key.contains('active')) {
|
||||||
|
background = blend(scheme.primary);
|
||||||
|
foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8);
|
||||||
|
} else if (key.contains('withdrawn')) {
|
||||||
|
background = blend(Colors.amber);
|
||||||
|
foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8);
|
||||||
|
} else if (key.contains('stored') || key.contains('unknown')) {
|
||||||
|
background = blend(Colors.grey);
|
||||||
|
foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75);
|
||||||
|
} else {
|
||||||
|
background = scheme.surfaceContainerHighest;
|
||||||
|
foreground = scheme.onSurface;
|
||||||
|
}
|
||||||
|
return (background, foreground);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showLocoInfo(LocoSummary loco) async {
|
Future<void> _showLocoInfo(LocoSummary loco) async {
|
||||||
await showModalBottomSheet(
|
await showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -750,4 +711,70 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
if (value == null) return '0';
|
if (value == null) return '0';
|
||||||
return value.toStringAsFixed(1);
|
return value.toStringAsFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterInput(
|
||||||
|
BuildContext context,
|
||||||
|
EventField field,
|
||||||
|
bool isMobile,
|
||||||
|
) {
|
||||||
|
final width = isMobile ? double.infinity : 220.0;
|
||||||
|
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
|
||||||
|
final options = field.enumValues!.map((e) => e.toString()).toSet().toList();
|
||||||
|
final currentValue = _enumSelections[field.name];
|
||||||
|
if (currentValue != null && !options.contains(currentValue)) {
|
||||||
|
options.insert(0, currentValue);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: DropdownButtonFormField<String?>(
|
||||||
|
value: currentValue,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: field.display,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(value: null, child: Text('Any')),
|
||||||
|
...options
|
||||||
|
.map(
|
||||||
|
(value) => DropdownMenuItem(
|
||||||
|
value: value,
|
||||||
|
child: Text(value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_enumSelections[field.name] = val;
|
||||||
|
});
|
||||||
|
_refreshTraction();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller =
|
||||||
|
_dynamicControllers[field.name] ?? TextEditingController();
|
||||||
|
_dynamicControllers[field.name] = controller;
|
||||||
|
TextInputType? inputType;
|
||||||
|
if (field.type != null) {
|
||||||
|
final type = field.type!.toLowerCase();
|
||||||
|
if (type.contains('int') || type.contains('num') || type.contains('double')) {
|
||||||
|
inputType = const TextInputType.numberWithOptions(decimal: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: inputType,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: field.display,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _refreshTraction(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,51 +269,85 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
|
final data = context.read<DataService>();
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: FutureBuilder<List<TripLocoStat>>(
|
||||||
padding: const EdgeInsets.all(16.0),
|
future: data.fetchTripLocoStats(trip.id),
|
||||||
child: Column(
|
builder: (ctx, snapshot) {
|
||||||
mainAxisSize: MainAxisSize.min,
|
final items = snapshot.data ?? [];
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final loading =
|
||||||
children: [
|
snapshot.connectionState == ConnectionState.waiting;
|
||||||
Row(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Row(
|
||||||
icon: const Icon(Icons.arrow_back),
|
children: [
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.arrow_back),
|
||||||
Text(
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
trip.name,
|
),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
trip.name,
|
||||||
),
|
style: Theme.of(context)
|
||||||
),
|
.textTheme
|
||||||
const Spacer(),
|
.titleMedium
|
||||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SizedBox(
|
|
||||||
height: MediaQuery.of(context).size.height * 0.6,
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: trip.legs.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final leg = trip.legs[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.train),
|
|
||||||
title: Text('${leg.start} → ${leg.end}'),
|
|
||||||
subtitle: Text(_formatDate(leg.beginTime)),
|
|
||||||
trailing: Text(
|
|
||||||
leg.mileage?.toStringAsFixed(1) ?? '-',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
);
|
const Spacer(),
|
||||||
},
|
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (loading)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (items.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Text('No traction recorded for this trip yet.'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final loco = items[index];
|
||||||
|
final won = loco.won;
|
||||||
|
final isWon = won == true;
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.train),
|
||||||
|
title: Text('${loco.locoClass} ${loco.number}'),
|
||||||
|
subtitle:
|
||||||
|
loco.name == null || loco.name!.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(loco.name!),
|
||||||
|
trailing: Chip(
|
||||||
|
label: Text(isWon ? 'Won' : 'Dud'),
|
||||||
|
backgroundColor: isWon
|
||||||
|
? Colors.green.shade100
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isWon
|
||||||
|
? Colors.green.shade900
|
||||||
|
: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -188,18 +188,19 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
|
|
||||||
if (!_fetched) {
|
if (!_fetched) {
|
||||||
_fetched = true;
|
_fetched = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
Future(() async {
|
Future(() async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final auth = context.read<AuthService>();
|
final auth = context.read<AuthService>();
|
||||||
api.setTokenProvider(() => auth.token);
|
api.setTokenProvider(() => auth.token);
|
||||||
await auth.tryRestoreSession();
|
await auth.tryRestoreSession();
|
||||||
if (!auth.isLoggedIn) return;
|
if (!auth.isLoggedIn) return;
|
||||||
if (data.homepageStats == null) {
|
data.fetchEventFields();
|
||||||
data.fetchHomepageStats();
|
if (data.homepageStats == null) {
|
||||||
}
|
data.fetchHomepageStats();
|
||||||
if (data.legs.isEmpty) {
|
}
|
||||||
data.fetchLegs();
|
if (data.legs.isEmpty) {
|
||||||
|
data.fetchLegs();
|
||||||
}
|
}
|
||||||
if (data.traction.isEmpty) {
|
if (data.traction.isEmpty) {
|
||||||
data.fetchHadTraction();
|
data.fetchHadTraction();
|
||||||
|
|||||||
@@ -397,3 +397,56 @@ class TripDetail {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TripLocoStat {
|
||||||
|
final String locoClass;
|
||||||
|
final String number;
|
||||||
|
final String? name;
|
||||||
|
final bool won;
|
||||||
|
|
||||||
|
TripLocoStat({
|
||||||
|
required this.locoClass,
|
||||||
|
required this.number,
|
||||||
|
required this.won,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TripLocoStat.fromJson(Map<String, dynamic> json) => TripLocoStat(
|
||||||
|
locoClass: json['loco_class'] ?? json['class'] ?? '',
|
||||||
|
number: json['loco_number'] ?? json['number'] ?? '',
|
||||||
|
name: json['loco_name'] ?? json['name'],
|
||||||
|
won: json['won'] == 1 ||
|
||||||
|
json['won'] == true ||
|
||||||
|
(json['won'] is String && json['won'].toString() == '1'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventField {
|
||||||
|
final String name;
|
||||||
|
final String display;
|
||||||
|
final String? type;
|
||||||
|
final List<String>? enumValues;
|
||||||
|
|
||||||
|
const EventField({
|
||||||
|
required this.name,
|
||||||
|
required this.display,
|
||||||
|
this.type,
|
||||||
|
this.enumValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventField.fromJson(Map<String, dynamic> json) {
|
||||||
|
final enumList = json['enum'];
|
||||||
|
List<String>? enumValues;
|
||||||
|
if (enumList is List) {
|
||||||
|
enumValues = enumList.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
final baseName = json['name']?.toString() ?? json['field']?.toString() ?? '';
|
||||||
|
final display = json['field']?.toString() ?? baseName;
|
||||||
|
return EventField(
|
||||||
|
name: baseName,
|
||||||
|
display: display,
|
||||||
|
type: json['type']?.toString(),
|
||||||
|
enumValues: enumValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/apiService.dart';
|
import 'package:mileograph_flutter/services/apiService.dart';
|
||||||
|
import 'package:mileograph_flutter/services/tokenStorageService.dart';
|
||||||
|
|
||||||
class AuthService extends ChangeNotifier {
|
class AuthService extends ChangeNotifier {
|
||||||
final ApiService api;
|
final ApiService api;
|
||||||
static const _tokenKey = 'auth_token';
|
|
||||||
bool _restoring = false;
|
bool _restoring = false;
|
||||||
|
|
||||||
// secure storage instance
|
final TokenStorageService _tokenStorage = TokenStorageService();
|
||||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
|
||||||
|
|
||||||
AuthService({required this.api});
|
AuthService({required this.api});
|
||||||
|
|
||||||
@@ -74,10 +72,10 @@ class AuthService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> tryRestoreSession() async {
|
Future<void> tryRestoreSession() async {
|
||||||
if (_restoring || _user != null) return;
|
if (_restoring || _user != null) return;
|
||||||
_restoring = true;
|
_restoring = true;
|
||||||
try {
|
try {
|
||||||
// read token from secure storage
|
// read token from secure storage (with fallback)
|
||||||
final token = await _storage.read(key: _tokenKey);
|
final token = await _tokenStorage.getToken();
|
||||||
if (token == null || token.isEmpty) return;
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
final userResponse = await api.get(
|
final userResponse = await api.get(
|
||||||
@@ -103,11 +101,11 @@ class AuthService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persistToken(String token) async {
|
Future<void> _persistToken(String token) async {
|
||||||
await _storage.write(key: _tokenKey, value: token);
|
await _tokenStorage.setToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearToken() async {
|
Future<void> _clearToken() async {
|
||||||
await _storage.delete(key: _tokenKey);
|
await _tokenStorage.clearToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> register({
|
Future<void> register({
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ class DataService extends ChangeNotifier {
|
|||||||
List<String> get locoClasses => _locoClasses;
|
List<String> get locoClasses => _locoClasses;
|
||||||
List<TripSummary> _tripList = [];
|
List<TripSummary> _tripList = [];
|
||||||
List<TripSummary> get tripList => _tripList;
|
List<TripSummary> get tripList => _tripList;
|
||||||
|
List<EventField> _eventFields = [];
|
||||||
|
List<EventField> get eventFields => _eventFields;
|
||||||
|
bool _isEventFieldsLoading = false;
|
||||||
|
bool get isEventFieldsLoading => _isEventFieldsLoading;
|
||||||
|
|
||||||
// Station Data
|
// Station Data
|
||||||
List<Station>? _cachedStations;
|
List<Station>? _cachedStations;
|
||||||
@@ -73,6 +77,17 @@ class DataService extends ChangeNotifier {
|
|||||||
bool _isOnThisDayLoading = false;
|
bool _isOnThisDayLoading = false;
|
||||||
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
||||||
|
|
||||||
|
static const List<EventField> _fallbackEventFields = [
|
||||||
|
EventField(name: 'operator', display: 'Operator'),
|
||||||
|
EventField(name: 'status', display: 'Status'),
|
||||||
|
EventField(name: 'evn', display: 'EVN'),
|
||||||
|
EventField(name: 'owner', display: 'Owner'),
|
||||||
|
EventField(name: 'location', display: 'Location'),
|
||||||
|
EventField(name: 'livery', display: 'Livery'),
|
||||||
|
EventField(name: 'domain', display: 'Domain'),
|
||||||
|
EventField(name: 'type', display: 'Type'),
|
||||||
|
];
|
||||||
|
|
||||||
void _notifyAsync() {
|
void _notifyAsync() {
|
||||||
// Always defer to the next frame to avoid setState during build.
|
// Always defer to the next frame to avoid setState during build.
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -260,6 +275,75 @@ class DataService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/trips/stats?trip_id=$tripId');
|
||||||
|
if (json is List) {
|
||||||
|
return json
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => TripLocoStat.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (json is Map && json['locos'] is List) {
|
||||||
|
return (json['locos'] as List)
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => TripLocoStat.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch trip loco stats: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchEventFields({bool force = false}) async {
|
||||||
|
if (_eventFields.isNotEmpty && !force) return;
|
||||||
|
_isEventFieldsLoading = true;
|
||||||
|
_notifyAsync();
|
||||||
|
try {
|
||||||
|
final json = await api.get('/event/fields');
|
||||||
|
List<EventField> fields = _parseEventFields(json);
|
||||||
|
if (fields.isEmpty) {
|
||||||
|
fields = _fallbackEventFields;
|
||||||
|
}
|
||||||
|
_eventFields = fields;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch event fields: $e');
|
||||||
|
_eventFields = _fallbackEventFields;
|
||||||
|
} finally {
|
||||||
|
_isEventFieldsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EventField> _parseEventFields(dynamic json) {
|
||||||
|
if (json is List) {
|
||||||
|
return json
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(EventField.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (json is Map) {
|
||||||
|
if (json['fields'] is List) {
|
||||||
|
return (json['fields'] as List)
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(EventField.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
// If map of name -> definition
|
||||||
|
return json.entries
|
||||||
|
.where((entry) => entry.value is Map<String, dynamic>)
|
||||||
|
.map((entry) {
|
||||||
|
final map = Map<String, dynamic>.from(entry.value);
|
||||||
|
map['name'] = entry.key;
|
||||||
|
return EventField.fromJson(map);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchTrips() async {
|
Future<void> fetchTrips() async {
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/mileage');
|
final json = await api.get('/trips/mileage');
|
||||||
@@ -314,6 +398,7 @@ class DataService extends ChangeNotifier {
|
|||||||
_onThisDay = [];
|
_onThisDay = [];
|
||||||
_trips = [];
|
_trips = [];
|
||||||
_tripDetails = [];
|
_tripDetails = [];
|
||||||
|
_eventFields = [];
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Stores the auth token in secure storage and falls back to SharedPreferences
|
||||||
|
/// so debug builds and platforms without a working keyring still persist.
|
||||||
class TokenStorageService {
|
class TokenStorageService {
|
||||||
// Singleton pattern (optional but usually handy for services)
|
|
||||||
TokenStorageService._internal();
|
TokenStorageService._internal();
|
||||||
|
|
||||||
static final TokenStorageService _instance = TokenStorageService._internal();
|
static final TokenStorageService _instance = TokenStorageService._internal();
|
||||||
@@ -9,26 +11,45 @@ class TokenStorageService {
|
|||||||
factory TokenStorageService() => _instance;
|
factory TokenStorageService() => _instance;
|
||||||
|
|
||||||
static const _tokenKey = 'auth_token';
|
static const _tokenKey = 'auth_token';
|
||||||
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
|
|
||||||
// Use const constructor for secure storage
|
Future<SharedPreferences> get _prefs async =>
|
||||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
await SharedPreferences.getInstance();
|
||||||
|
|
||||||
/// Save or update the token
|
|
||||||
Future<void> setToken(String token) async {
|
Future<void> setToken(String token) async {
|
||||||
await _storage.write(key: _tokenKey, value: token);
|
try {
|
||||||
|
await _secureStorage.write(key: _tokenKey, value: token);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore secure storage failures in debug/unsupported environments
|
||||||
|
}
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.setString(_tokenKey, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the stored token (null if none)
|
|
||||||
Future<String?> getToken() async {
|
Future<String?> getToken() async {
|
||||||
return _storage.read(key: _tokenKey);
|
try {
|
||||||
|
final secured = await _secureStorage.read(key: _tokenKey);
|
||||||
|
if (secured != null && secured.isNotEmpty) {
|
||||||
|
return secured;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore and fall back
|
||||||
|
}
|
||||||
|
final prefs = await _prefs;
|
||||||
|
final token = prefs.getString(_tokenKey);
|
||||||
|
return (token == null || token.isEmpty) ? null : token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the token
|
|
||||||
Future<void> clearToken() async {
|
Future<void> clearToken() async {
|
||||||
await _storage.delete(key: _tokenKey);
|
try {
|
||||||
|
await _secureStorage.delete(key: _tokenKey);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional: check quickly if a token exists
|
|
||||||
Future<bool> hasToken() async {
|
Future<bool> hasToken() async {
|
||||||
final token = await getToken();
|
final token = await getToken();
|
||||||
return token != null && token.isNotEmpty;
|
return token != null && token.isNotEmpty;
|
||||||
|
|||||||
Reference in New Issue
Block a user