Compare commits
2 Commits
45042b5001
...
3b7ec31e5d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b7ec31e5d | |||
| e9b328e7e6 |
@@ -18,10 +18,12 @@ class LocoTimelinePage extends StatefulWidget {
|
||||
super.key,
|
||||
required this.locoId,
|
||||
required this.locoLabel,
|
||||
this.forceShowPending = false,
|
||||
});
|
||||
|
||||
final int locoId;
|
||||
final String locoLabel;
|
||||
final bool forceShowPending;
|
||||
|
||||
@override
|
||||
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
|
||||
@@ -41,7 +43,13 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await _restorePendingVisibility();
|
||||
if (widget.forceShowPending) {
|
||||
setState(() {
|
||||
_showPending = true;
|
||||
});
|
||||
} else {
|
||||
await _restorePendingVisibility();
|
||||
}
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
});
|
||||
|
||||
@@ -716,9 +716,14 @@ bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
|
||||
|
||||
List<_ValueSegment> _segmentsForEntries(
|
||||
List<LocoAttrVersion> items,
|
||||
DateTime now,
|
||||
) {
|
||||
DateTime now, {
|
||||
bool? clampToNextStart,
|
||||
}) {
|
||||
if (items.isEmpty) return const [];
|
||||
final hasPending = items.any((e) => e.isPending);
|
||||
final hasApproved = items.any((e) => !e.isPending);
|
||||
final shouldClamp =
|
||||
clampToNextStart ?? (hasPending && hasApproved);
|
||||
final sorted = [...items];
|
||||
sorted.sort(
|
||||
(a, b) => (_effectiveStart(a) ?? now)
|
||||
@@ -731,7 +736,13 @@ List<_ValueSegment> _segmentsForEntries(
|
||||
final nextStart = i < sorted.length - 1
|
||||
? _effectiveStart(sorted[i + 1])
|
||||
: null;
|
||||
final rawEnd = entry.validTo ?? nextStart ?? now;
|
||||
DateTime? rawEnd = entry.validTo;
|
||||
if (nextStart != null) {
|
||||
if (rawEnd == null || (shouldClamp && nextStart.isBefore(rawEnd))) {
|
||||
rawEnd = nextStart;
|
||||
}
|
||||
}
|
||||
rawEnd ??= now;
|
||||
final end = _safeEnd(start, rawEnd);
|
||||
segments.add(
|
||||
_ValueSegment(
|
||||
@@ -745,6 +756,53 @@ List<_ValueSegment> _segmentsForEntries(
|
||||
return segments;
|
||||
}
|
||||
|
||||
List<LocoAttrVersion> _applyPendingOverrides(
|
||||
List<LocoAttrVersion> approved,
|
||||
List<LocoAttrVersion> pending,
|
||||
) {
|
||||
if (pending.isEmpty) return approved;
|
||||
final pendingByStart = <int, LocoAttrVersion>{};
|
||||
final extraPending = <LocoAttrVersion>[];
|
||||
for (final entry in pending) {
|
||||
final start = _effectiveStart(entry);
|
||||
if (start == null) continue;
|
||||
final key = _startKey(start);
|
||||
pendingByStart[key] = entry;
|
||||
}
|
||||
|
||||
final applied = <LocoAttrVersion>[];
|
||||
final seenKeys = <int>{};
|
||||
for (final entry in approved) {
|
||||
final start = _effectiveStart(entry);
|
||||
if (start == null) continue;
|
||||
final key = _startKey(start);
|
||||
if (pendingByStart.containsKey(key)) {
|
||||
if (!seenKeys.contains(key)) {
|
||||
applied.add(pendingByStart[key]!);
|
||||
seenKeys.add(key);
|
||||
}
|
||||
} else {
|
||||
applied.add(entry);
|
||||
seenKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in pendingByStart.values) {
|
||||
final start = _effectiveStart(entry);
|
||||
if (start == null) continue;
|
||||
final key = _startKey(start);
|
||||
if (!seenKeys.contains(key)) {
|
||||
extraPending.add(entry);
|
||||
seenKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (extraPending.isNotEmpty) {
|
||||
applied.addAll(extraPending);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
List<DateTime> _buildBoundaries(
|
||||
List<_ValueSegment> segments,
|
||||
DateTime now,
|
||||
@@ -904,14 +962,17 @@ class _TimelineModel {
|
||||
}
|
||||
|
||||
final hasOverlap = overlapByUser.isNotEmpty;
|
||||
final canToggle = pending.length > 1 && !hasOverlap;
|
||||
final canToggle = pending.isNotEmpty && !hasOverlap;
|
||||
final isExpanded = expandedAttrCodes.contains(attr);
|
||||
final shouldShowPendingRows = isExpanded || hasOverlap;
|
||||
|
||||
final nonOverlapPending =
|
||||
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
|
||||
final baseEntries = isExpanded ? approved : [...approved, ...nonOverlapPending];
|
||||
final baseSegments =
|
||||
isExpanded ? approvedSegments : _segmentsForEntries(baseEntries, now);
|
||||
final baseEntries =
|
||||
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
|
||||
final baseSegments = shouldShowPendingRows
|
||||
? approvedSegments
|
||||
: _segmentsForEntries(baseEntries, now);
|
||||
|
||||
rowSpecs.add(
|
||||
_TimelineRowSpec.primary(
|
||||
@@ -923,7 +984,6 @@ class _TimelineModel {
|
||||
);
|
||||
allSegments.addAll(baseSegments);
|
||||
|
||||
final shouldShowPendingRows = isExpanded || hasOverlap;
|
||||
if (shouldShowPendingRows) {
|
||||
final users = isExpanded
|
||||
? pendingByUser.keys.toList()
|
||||
@@ -934,11 +994,9 @@ class _TimelineModel {
|
||||
? (pendingByUser[user] ?? const [])
|
||||
: (overlapByUser[user] ?? const []);
|
||||
if (pendingEntries.isEmpty) continue;
|
||||
final userPendingSegments = _segmentsForEntries(pendingEntries, now);
|
||||
final combinedSegments = [
|
||||
...approvedSegments,
|
||||
...userPendingSegments,
|
||||
];
|
||||
final appliedEntries =
|
||||
_applyPendingOverrides(approved, pendingEntries);
|
||||
final combinedSegments = _segmentsForEntries(appliedEntries, now);
|
||||
rowSpecs.add(
|
||||
_TimelineRowSpec.pending(
|
||||
attrCode: attr,
|
||||
|
||||
@@ -4,6 +4,7 @@ enum _TractionMoreAction {
|
||||
classStats,
|
||||
classLeaderboard,
|
||||
adminPending,
|
||||
adminPendingChanges,
|
||||
}
|
||||
|
||||
class TractionPage extends StatefulWidget {
|
||||
@@ -708,6 +709,19 @@ class _TractionPageState extends State<TractionPage> {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case _TractionMoreAction.adminPendingChanges:
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await context.push('/traction/changes');
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Unable to open pending changes'),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
@@ -765,6 +779,12 @@ class _TractionPageState extends State<TractionPage> {
|
||||
),
|
||||
),
|
||||
);
|
||||
items.add(
|
||||
const PopupMenuItem(
|
||||
value: _TractionMoreAction.adminPendingChanges,
|
||||
child: Text('Pending changes'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
|
||||
152
lib/components/pages/traction/traction_pending_changes_page.dart
Normal file
152
lib/components/pages/traction/traction_pending_changes_page.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
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:mileograph_flutter/services/authservice.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class TractionPendingChangesPage extends StatefulWidget {
|
||||
const TractionPendingChangesPage({super.key});
|
||||
|
||||
@override
|
||||
State<TractionPendingChangesPage> createState() =>
|
||||
_TractionPendingChangesPageState();
|
||||
}
|
||||
|
||||
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
|
||||
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 json = await api.get('/event/pending/locos');
|
||||
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) {
|
||||
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
title: const Text('Pending changes'),
|
||||
),
|
||||
body: isElevated
|
||||
? RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _buildBody(context),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
Text('Admin access required.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 changes: $_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 changes 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(),
|
||||
'showPending': true,
|
||||
},
|
||||
),
|
||||
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
|
||||
onActionComplete: _load,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
|
||||
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_page.dart';
|
||||
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
|
||||
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
||||
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
|
||||
@@ -221,6 +222,11 @@ class _MyAppState extends State<MyApp> {
|
||||
path: '/traction/pending',
|
||||
builder: (context, state) => const TractionPendingPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/traction/changes',
|
||||
builder: (context, state) =>
|
||||
const TractionPendingChangesPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
@@ -238,7 +244,15 @@ class _MyAppState extends State<MyApp> {
|
||||
label = extra;
|
||||
}
|
||||
if (label.trim().isEmpty) label = 'Loco $locoId';
|
||||
return LocoTimelinePage(locoId: locoId, locoLabel: label);
|
||||
bool showPending = false;
|
||||
if (extra is Map && extra['showPending'] is bool) {
|
||||
showPending = extra['showPending'] as bool;
|
||||
}
|
||||
return LocoTimelinePage(
|
||||
locoId: locoId,
|
||||
locoLabel: label,
|
||||
forceShowPending: showPending,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
Reference in New Issue
Block a user