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
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
140
lib/components/pages/traction/traction_pending_page.dart
Normal file
140
lib/components/pages/traction/traction_pending_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user