add ability for non admins to add new traction, pending approval. Various QoL updates
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Successful in 1m14s
Release / android-build (push) Successful in 5m33s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s

This commit is contained in:
2026-01-05 22:11:02 +00:00
parent a755644c31
commit d5083e1cc7
18 changed files with 1585 additions and 173 deletions

View File

@@ -1,14 +1,24 @@
part of 'traction.dart';
enum _TractionMoreAction {
classStats,
classLeaderboard,
adminPending,
}
class TractionPage extends StatefulWidget {
const TractionPage({
super.key,
this.selectionMode = false,
this.selectionSingle = false,
this.replacementPendingLocoId,
this.onSelect,
this.selectedKeys = const {},
});
final bool selectionMode;
final bool selectionSingle;
final int? replacementPendingLocoId;
final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys;
@@ -606,60 +616,106 @@ class _TractionPageState extends State<TractionPage> {
icon: const Icon(Icons.refresh),
);
final classStatsButton = !_hasClassQuery
? null
: FilledButton.tonalIcon(
onPressed: _toggleClassStatsPanel,
icon: Icon(
_showClassStatsPanel ? Icons.bar_chart : Icons.insights,
),
label: Text(
_showClassStatsPanel ? 'Hide class stats' : 'Class stats',
),
);
final classLeaderboardButton = !_hasClassQuery
? null
: FilledButton.tonalIcon(
onPressed: _toggleClassLeaderboardPanel,
icon: Icon(
_showClassLeaderboardPanel ? Icons.emoji_events : Icons.leaderboard,
),
label: Text(
_showClassLeaderboardPanel ? 'Hide class leaderboard' : 'Class leaderboard',
),
);
final hasClassActions = _hasClassQuery;
final newTractionButton = !isElevated
final newTractionButton = FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (!mounted) return;
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
_refreshTraction();
} else if (createdClass == '') {
_refreshTraction();
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
);
final hasAdminActions = isElevated;
final hasMoreMenu = hasClassActions || hasAdminActions;
final moreButton = !hasMoreMenu
? null
: FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (!mounted) return;
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
_refreshTraction();
} else if (createdClass == '') {
_refreshTraction();
: PopupMenuButton<_TractionMoreAction>(
tooltip: 'More options',
onSelected: (action) async {
switch (action) {
case _TractionMoreAction.classStats:
_toggleClassStatsPanel();
break;
case _TractionMoreAction.classLeaderboard:
_toggleClassLeaderboardPanel();
break;
case _TractionMoreAction.adminPending:
final messenger = ScaffoldMessenger.of(context);
try {
await context.push('/traction/pending');
if (!mounted) return;
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Unable to open pending locos'),
),
);
}
break;
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
itemBuilder: (context) {
final items = <PopupMenuEntry<_TractionMoreAction>>[];
if (hasClassActions) {
items.add(
const PopupMenuItem(
value: _TractionMoreAction.classStats,
child: Text('Class stats'),
),
);
}
if (hasClassActions) {
items.add(
const PopupMenuItem(
value: _TractionMoreAction.classLeaderboard,
child: Text('Class leaderboard'),
),
);
}
if (items.isNotEmpty && hasAdminActions) {
items.add(const PopupMenuDivider());
}
if (hasAdminActions) {
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPending,
child: Text('Pending locos'),
),
);
}
return items;
},
child: IgnorePointer(
child: FilledButton.tonalIcon(
onPressed: () {},
icon: const Icon(Icons.more_horiz),
label: const Text('More'),
),
),
);
final desktopActions = [
refreshButton,
if (classStatsButton != null) classStatsButton,
if (classLeaderboardButton != null) classLeaderboardButton,
if (newTractionButton != null) newTractionButton,
newTractionButton,
if (moreButton != null) moreButton,
];
final mobileActions = [
if (newTractionButton != null) newTractionButton,
if (classStatsButton != null) classStatsButton,
if (classLeaderboardButton != null) classLeaderboardButton,
if (moreButton != null) moreButton,
newTractionButton,
refreshButton,
];
@@ -1054,6 +1110,12 @@ class _TractionPageState extends State<TractionPage> {
if (widget.onSelect != null) {
widget.onSelect!(loco);
}
if (widget.selectionMode && widget.selectionSingle) {
if (mounted) {
context.pop(loco);
}
return;
}
setState(() {
if (_selectedKeys.contains(keyVal)) {
_selectedKeys.remove(keyVal);
@@ -1063,6 +1125,81 @@ class _TractionPageState extends State<TractionPage> {
});
}
Future<void> _confirmReplacePending(LocoSummary replacement) async {
final pendingId = widget.replacementPendingLocoId;
if (pendingId == null) return;
final navContext = context;
final messenger = ScaffoldMessenger.of(navContext);
String rejectionReason = '';
final confirmed = await showDialog<bool>(
context: navContext,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final canSubmit = rejectionReason.trim().isNotEmpty;
return AlertDialog(
title: const Text('Replace pending loco?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Replace pending loco with ${replacement.locoClass} ${replacement.number}?',
),
const SizedBox(height: 12),
TextField(
autofocus: true,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Rejection reason',
hintText: 'Reason for replacing this loco',
),
onChanged: (val) => setState(() {
rejectionReason = val;
}),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed:
canSubmit ? () => Navigator.of(dialogContext).pop(true) : null,
child: const Text('Replace'),
),
],
);
},
);
},
);
if (confirmed != true) return;
if (!navContext.mounted) return;
try {
final data = navContext.read<DataService>();
await data.rejectPendingLoco(
locoId: pendingId,
replacementLocoId: replacement.id,
rejectedReason: rejectionReason,
);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Pending loco replaced')),
);
navContext.pop();
}
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to replace loco: $e')),
);
}
}
}
bool _isSelected(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}';
return _selectedKeys.contains(keyVal);
@@ -1195,15 +1332,27 @@ class _TractionPageState extends State<TractionPage> {
(context, index) {
if (index < traction.length) {
final loco = traction[index];
return TractionCard(
loco: loco,
selectionMode: widget.selectionMode,
isSelected: _isSelected(loco),
onShowInfo: () => showTractionDetails(context, loco),
onOpenTimeline: () => _openTimeline(loco),
onOpenLegs: () => _openLegs(loco),
onToggleSelect:
widget.selectionMode ? () => _toggleSelection(loco) : null,
return TractionCard(
loco: loco,
selectionMode: widget.selectionMode,
isSelected: _isSelected(loco),
onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: () => _refreshTraction(preservePosition: true),
),
onOpenTimeline: () => _openTimeline(loco),
onOpenLegs: () => _openLegs(loco),
onActionComplete: _refreshTraction,
onToggleSelect: widget.selectionMode &&
widget.replacementPendingLocoId == null
? () => _toggleSelection(loco)
: null,
onReplacePending: widget.selectionMode &&
widget.selectionSingle &&
widget.replacementPendingLocoId != null
? () => _confirmReplacePending(loco)
: null,
);
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:provider/provider.dart';
class TractionPendingPage extends StatefulWidget {
const TractionPendingPage({super.key});
@override
State<TractionPendingPage> createState() => _TractionPendingPageState();
}
class _TractionPendingPageState extends State<TractionPendingPage> {
bool _isLoading = false;
String? _error;
List<LocoSummary> _locos = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = context.read<ApiService>();
final params = '?limit=200&offset=0';
final json = await api.get('/loco/pending$params');
if (json is List) {
setState(() {
_locos = json
.whereType<Map>()
.map((e) => LocoSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
});
} else {
setState(() {
_error = 'Unexpected response';
_locos = const [];
});
}
} catch (e) {
setState(() {
_error = e.toString();
_locos = const [];
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending traction'),
),
body: RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
),
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading && _locos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Failed to load pending traction: $_error',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
if (_locos.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('No pending traction found.'),
],
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _locos.length,
itemBuilder: (context, index) {
final loco = _locos[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: TractionCard(
loco: loco,
selectionMode: false,
isSelected: false,
onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: _load,
),
onOpenTimeline: () => context.push(
'/traction/${loco.id}/timeline',
extra: {'label': '${loco.locoClass} ${loco.number}'.trim()},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}