Layout changes, fix bugs in new entry page

This commit is contained in:
2025-12-22 17:23:21 +00:00
parent 63b545c7a3
commit 45d543498f
20 changed files with 779 additions and 192 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/objects/objects.dart';
@@ -32,6 +33,7 @@ class _DashboardState extends State<Dashboard> {
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]);
},
child: LayoutBuilder(
@@ -41,7 +43,7 @@ class _DashboardState extends State<Dashboard> {
context,
totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(),
trips: data.trips.length,
legCount: stats?.legCount ?? data.trips.length,
);
return Stack(
children: [
@@ -138,7 +140,7 @@ class _DashboardState extends State<Dashboard> {
BuildContext context, {
required double totalMileage,
required double currentYearMileage,
required int trips,
required int legCount,
}) {
final textTheme = Theme.of(context).textTheme;
Widget metricCard(String label, String value) {
@@ -173,7 +175,7 @@ class _DashboardState extends State<Dashboard> {
return [
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
metricCard('Trips logged', trips.toString()),
metricCard('Entries logged', legCount.toString()),
];
}
@@ -211,8 +213,6 @@ class _DashboardState extends State<Dashboard> {
),
),
const SizedBox(height: 12),
_buildQuickCalcCard(context),
const SizedBox(height: 12),
_buildTripsCard(context, data),
],
);
@@ -220,10 +220,13 @@ class _DashboardState extends State<Dashboard> {
Widget _buildSidebar(BuildContext context, DataService data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TopTractionPanel(),
const TopTractionPanel(),
const SizedBox(height: 12),
LeaderboardPanel(),
const LeaderboardPanel(),
const SizedBox(height: 12),
const LatestLocoChangesPanel(),
],
);
}
@@ -313,22 +316,6 @@ class _DashboardState extends State<Dashboard> {
);
}
Widget _buildQuickCalcCard(BuildContext context) {
return _buildCard(
context,
title: 'Quick mileage calculator',
action: TextButton.icon(
onPressed: () => context.push('/calculator'),
icon: const Icon(Icons.open_in_new),
label: const Text('Open calculator'),
),
child: Text(
'Jump into the route calculator to quickly total a journey before saving it.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
Widget _buildTripsCard(BuildContext context, DataService data) {
final tripsUnsorted = data.trips;
List trips = [];
@@ -353,7 +340,6 @@ class _DashboardState extends State<Dashboard> {
contentPadding: EdgeInsets.zero,
title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
trailing: const Icon(Icons.chevron_right),
);
}).toList(),
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
@@ -211,7 +212,7 @@ class _LegsPageState extends State<LegsPage> {
else
Column(
children: [
...legs.map((leg) => LegCard(leg: leg)),
..._buildLegsWithDividers(context, legs),
const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading)
Align(
@@ -238,6 +239,57 @@ class _LegsPageState extends State<LegsPage> {
);
}
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) {
final widgets = <Widget>[];
String? currentDate;
double dayMileage = 0;
final dayLegs = <Leg>[];
void flushDay() {
if (currentDate == null) return;
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Expanded(
child: Text(
currentDate!,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
'${dayMileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map((leg) => LegCard(leg: leg, showDate: false)),
);
dayLegs.clear();
}
for (final leg in legs) {
final dateStr = _formatDate(leg.beginTime) ?? '';
if (currentDate != null && dateStr != currentDate) {
flushDay();
dayMileage = 0;
}
currentDate = dateStr;
dayLegs.add(leg);
dayMileage += leg.mileage;
}
flushDay();
return widgets;
}
String? _formatDate(DateTime? date) {
if (date == null) return null;
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';

View File

@@ -443,53 +443,59 @@ class _ValueBlockMenu extends StatelessWidget {
return _ValueBlockView(block: block);
}
Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context);
final renderBox = overlay?.context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final position = RelativeRect.fromRect(
Rect.fromLTWH(
globalPosition.dx,
globalPosition.dy,
1,
1,
),
Offset.zero & renderBox.size,
);
final action = await showMenu<_TimelineBlockAction>(
context: context,
position: position,
items: [
if (onEditEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.edit,
child: Text('Edit'),
),
if (onDeleteEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.delete,
child: Text('Delete'),
),
],
);
final entry = block.entry;
if (action == null || entry == null) return;
switch (action) {
case _TimelineBlockAction.edit:
onEditEntry?.call(entry);
break;
case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry);
break;
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) async {
final overlay = Overlay.of(context);
final renderBox = overlay.context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact();
}
final anchor = details.globalPosition + const Offset(0, -8);
final position = RelativeRect.fromRect(
Rect.fromLTWH(
anchor.dx,
anchor.dy,
1,
1,
),
Offset.zero & renderBox.size,
);
final action = await showMenu<_TimelineBlockAction>(
context: context,
position: position,
items: [
if (onEditEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.edit,
child: Text('Edit'),
),
if (onDeleteEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.delete,
child: Text('Delete'),
),
],
);
final entry = block.entry;
if (action == null || entry == null) return;
switch (action) {
case _TimelineBlockAction.edit:
onEditEntry?.call(entry);
break;
case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry);
break;
}
await showContextMenuAt(details.globalPosition);
},
onSecondaryTapDown: (details) async {
await showContextMenuAt(details.globalPosition);
},
child: _ValueBlockView(block: block),
);

View File

@@ -84,6 +84,32 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
}
}
Future<void> _saveDraftManually() async {
if (_savingDraft) return;
if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Nothing to save yet.')),
);
return;
}
final hadDraft = _activeDraftId != null;
_setState(() => _savingDraft = true);
try {
await _saveDraftEntry(draftId: _activeDraftId);
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(hadDraft ? 'Draft updated' : 'Draft saved')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Failed to save draft: $e')),
);
} finally {
if (mounted) _setState(() => _savingDraft = false);
}
}
Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled) return;
final prefs = await SharedPreferences.getInstance();
@@ -212,6 +238,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(),
"mode": _useManualMileage ? 'manual' : 'auto',
"payload": payload,
"mileageText": _mileageController.text.trim(),
"routeResult": _routeResult == null
? null
: {

View File

@@ -27,6 +27,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
int? _selectedTripId;
bool _restoringDraft = false;
bool _loadingEdit = false;
bool _savingDraft = false;
String? _loadError;
Map<String, dynamic>? _lastSubmittedSnapshot;
Map<String, dynamic>? _loadedDraftSnapshot;
@@ -48,7 +49,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (!mounted) return;
final data = context.read<DataService>();
data.fetchClassList();
data.fetchTrips();
data.fetchTripOptions();
if (_draftPersistenceEnabled) {
_loadDraft();
}
@@ -146,20 +147,31 @@ 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 {
await api.put('/trips/new', {"trip_name": result});
await data.fetchTrips();
if (!context.mounted) return;
final trips = data.tripList;
final match = trips.firstWhere(
(t) => t.tripName == result,
orElse: () => trips.isNotEmpty
? trips.first
: TripSummary(tripId: 0, tripName: result, tripMileage: 0),
);
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 {
match = trips.firstWhere(
(t) =>
(apiTripId != null && t.tripId == apiTripId) ||
t.tripName == result,
);
} catch (_) {
match = TripSummary(
tripId: apiTripId ?? 0,
tripName: result,
tripMileage: 0,
);
data.upsertTripSummary(match);
}
setState(() => _selectedTripId = match.tripId);
_saveDraft();
} catch (e) {
@@ -176,9 +188,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
}
Future<void> _openCalculator() async {
final initialStations = _routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute
: (_routeResult?.calculatedRoute ?? const []);
final result = await Navigator.of(context).push<RouteResult>(
MaterialPageRoute(
builder: (_) => _CalculatorPickerPage(
initialStations: initialStations.isEmpty ? null : initialStations,
onResult: (res) => Navigator.of(context).pop(res),
),
),
@@ -373,6 +389,25 @@ class _NewEntryPageState extends State<NewEntryPage> {
icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'),
),
const SizedBox(width: 12),
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing || _savingDraft || _submitting
? null
: _saveDraftManually,
icon: _savingDraft
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_alt, size: 16),
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
),
const Spacer(),
TextButton.icon(
style: TextButton.styleFrom(

View File

@@ -1,8 +1,12 @@
part of 'new_entry.dart';
class _CalculatorPickerPage extends StatelessWidget {
const _CalculatorPickerPage({required this.onResult});
const _CalculatorPickerPage({
required this.onResult,
this.initialStations,
});
final ValueChanged<RouteResult> onResult;
final List<String>? initialStations;
@override
Widget build(BuildContext context) {
@@ -14,8 +18,10 @@ class _CalculatorPickerPage extends StatelessWidget {
),
title: const Text('Mileage calculator'),
),
body: RouteCalculator(onApplyRoute: onResult),
body: RouteCalculator(
onApplyRoute: onResult,
initialStations: initialStations,
),
);
}
}

View File

@@ -27,7 +27,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final fieldList = missing.join(', ');
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
useRootNavigator: false,
builder: (dialogCtx) => AlertDialog(
title: const Text('Required field missing'),
content: Text(
missing.length == 1
@@ -36,7 +37,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('OK'),
),
],
@@ -46,7 +47,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final form = _formKey.currentState;
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return;
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
@@ -208,6 +211,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_selectedTripId = null;
_submitting = false;
_activeDraftId = null;
_savingDraft = false;
_loadedDraftSnapshot = null;
});
if (clearDraft) {
await _clearDraft();

View File

@@ -26,6 +26,55 @@ class _TripsPageState extends State<TripsPage> {
await context.read<DataService>().fetchTripDetails();
}
Future<void> _renameTrip(TripDetail trip, String newName) async {
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await api.post('/trips/rename', {
"trip_id": trip.id,
"trip_name": newName,
});
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to rename trip: $e')),
);
rethrow;
}
}
Future<String?> _promptTripName(BuildContext context, String initial) async {
final controller = TextEditingController(text: initial);
final newName = await showDialog<String>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Rename trip'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Trip name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Save'),
),
],
),
);
controller.dispose();
return newName;
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
@@ -218,52 +267,85 @@ class _TripsPageState extends State<TripsPage> {
context: context,
isScrollControlled: true,
builder: (_) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
bool renaming = false;
String tripName = trip.name;
return StatefulBuilder(
builder: (sheetCtx, setSheetState) {
Future<void> handleRename() async {
final newName =
await _promptTripName(sheetCtx, tripName) ?? tripName;
if (newName.isEmpty || newName == tripName) return;
setSheetState(() => renaming = true);
try {
await _renameTrip(trip, newName);
tripName = newName;
setSheetState(() {});
} finally {
if (mounted) setSheetState(() => renaming = false);
}
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(sheetCtx).pop(),
),
Expanded(
child: Text(
tripName,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: renaming
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
tooltip: 'Rename trip',
onPressed: renaming ? null : handleRename,
),
const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
],
),
Text(
trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
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),
),
);
},
),
),
const Spacer(),
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),
),
);
},
),
),
],
),
),
),
);
},
);
},
);