Compare commits

...

7 Commits

Author SHA1 Message Date
559f79b805 add transfer all button for admins
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 1m4s
Release / web-build (push) Successful in 2m47s
Release / android-build (push) Successful in 11m45s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 30s
2026-01-12 17:11:37 +00:00
3b7ec31e5d add refresh button to pending page
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Successful in 2m23s
Release / android-build (push) Successful in 7m48s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-12 16:37:01 +00:00
e9b328e7e6 improve pending visibility, allow multiple users to have pending changes against locos. 2026-01-12 16:16:36 +00:00
45042b5001 new pending visibility
Some checks failed
Release / meta (push) Successful in 8s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
2026-01-12 16:03:47 +00:00
5c0043146f minor page tweaks 2026-01-12 15:30:29 +00:00
91f5391684 fix edit widget issues 2026-01-12 15:00:09 +00:00
f06a1c75b6 Fix transfer functoin, add display for numer of pending locos
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m10s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 9m2s
Release / release-master (push) Successful in 17s
Release / release-dev (push) Successful in 19s
2026-01-06 14:44:12 +00:00
13 changed files with 1476 additions and 342 deletions

View File

@@ -133,6 +133,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
bool _loadingStations = false; bool _loadingStations = false;
RouteResult? _routeResult; RouteResult? _routeResult;
List<String>? _calculatedStations;
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
String? _errorMessage; String? _errorMessage;
@@ -178,6 +179,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (cleaned.length < 2) { if (cleaned.length < 2) {
setState(() { setState(() {
_routeResult = null; _routeResult = null;
_calculatedStations = null;
_errorMessage = 'Add at least two stations before calculating.'; _errorMessage = 'Add at least two stations before calculating.';
}); });
return; return;
@@ -185,6 +187,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
_routeResult = null; _routeResult = null;
_calculatedStations = null;
}); });
final api = context.read<ApiService>(); // context is valid here final api = context.read<ApiService>(); // context is valid here
try { try {
@@ -195,6 +198,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
if (res is Map && res['error'] == false) { if (res is Map && res['error'] == false) {
setState(() { setState(() {
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res)); _routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
_calculatedStations = List.from(cleaned);
}); });
final distance = (_routeResult?.distance ?? 0); final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance); widget.onDistanceComputed?.call(distance);
@@ -205,17 +209,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
).msg; ).msg;
}); });
} else { } else {
setState(() => _errorMessage = 'Failed to calculate route.'); setState(() {
_errorMessage = 'Failed to calculate route.';
_calculatedStations = null;
});
} }
} catch (e) { } catch (e) {
setState(() => _errorMessage = 'Failed to calculate route: $e'); setState(() {
_errorMessage = 'Failed to calculate route: $e';
_calculatedStations = null;
});
} }
} }
void _markRouteDirty() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = null;
}
void _addStation() { void _addStation() {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.add(''); data.stations.add('');
_markRouteDirty();
}); });
} }
@@ -223,6 +240,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.removeAt(index); data.stations.removeAt(index);
_markRouteDirty();
}); });
} }
@@ -230,6 +248,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations[index] = value; data.stations[index] = value;
_markRouteDirty();
}); });
} }
@@ -237,14 +256,91 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations = ['']; data.stations = [''];
_routeResult = null; _markRouteDirty();
_errorMessage = null;
}); });
} }
bool _isResultCurrent(List<String> stations) {
if (_routeResult == null || _calculatedStations == null) return false;
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length != _calculatedStations!.length) return false;
for (var i = 0; i < cleaned.length; i++) {
if (cleaned[i] != _calculatedStations![i]) return false;
}
return true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final isCompact = MediaQuery.of(context).size.width < 600;
final showApply =
widget.onApplyRoute != null && _isResultCurrent(data.stations);
final primaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 14 : 20,
vertical: isCompact ? 10 : 14,
);
final secondaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 10 : 16,
vertical: isCompact ? 8 : 12,
);
final primaryStyle = FilledButton.styleFrom(
padding: primaryPadding,
minimumSize: Size(0, isCompact ? 38 : 46),
);
final secondaryStyle = OutlinedButton.styleFrom(
padding: secondaryPadding,
minimumSize: Size(0, isCompact ? 34 : 42),
);
Widget buildSecondaryButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
if (isCompact) {
return Tooltip(
message: label,
child: OutlinedButton(
onPressed: onPressed,
style: secondaryStyle,
child: Icon(icon, size: 20),
),
);
}
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 20),
label: Text(label),
style: secondaryStyle,
);
}
Widget buildPrimaryAction({required bool fullWidth}) {
final button = showApply
? FilledButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
style: primaryStyle,
)
: FilledButton.icon(
onPressed: () async {
await _calculateRoute(data.stations);
},
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
style: primaryStyle,
);
final key =
ValueKey<String>(showApply ? 'apply-primary-action' : 'calc-primary-action');
if (!fullWidth) return KeyedSubtree(key: key, child: button);
return KeyedSubtree(
key: key,
child: SizedBox(width: double.infinity, child: button),
);
}
return Column( return Column(
children: [ children: [
Align( Align(
@@ -301,6 +397,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
final moved = data.stations.removeAt(oldIndex); final moved = data.stations.removeAt(oldIndex);
data.stations.insert(newIndex, moved); data.stations.insert(newIndex, moved);
_markRouteDirty();
}); });
}, },
children: List.generate(data.stations.length, (index) { children: List.generate(data.stations.length, (index) {
@@ -364,54 +461,92 @@ class _RouteCalculatorState extends State<RouteCalculator> {
context.push('/calculator/details', extra: result); context.push('/calculator/details', extra: result);
}, },
), ),
if (widget.onApplyRoute != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
),
),
] ]
else else
SizedBox.shrink(), SizedBox.shrink(),
const SizedBox(height: 10), const SizedBox(height: 10),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: isCompact
alignment: WrapAlignment.center, ? Column(
spacing: 12,
runSpacing: 8,
children: [ children: [
...(() { Row(
final reverseButton = ElevatedButton.icon( mainAxisAlignment: MainAxisAlignment.center,
icon: const Icon(Icons.swap_horiz), children: [
label: const Text('Reverse route'), buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async { onPressed: () async {
setState(() { setState(() {
data.stations = data.stations.reversed.toList(); data.stations = data.stations.reversed.toList();
}); });
await _calculateRoute(data.stations); await _calculateRoute(data.stations);
}, },
); ),
final addButton = ElevatedButton.icon( const SizedBox(width: 12),
icon: const Icon(Icons.add), buildSecondaryButton(
label: const Text('Add Station'), icon: Icons.add,
label: 'Add station',
onPressed: _addStation, onPressed: _addStation,
),
],
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
); );
final calculateButton = ElevatedButton.icon( return ScaleTransition(
icon: const Icon(Icons.route), scale:
label: const Text('Calculate Route'), Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
);
},
child: buildPrimaryAction(fullWidth: true),
),
),
],
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async { onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations); await _calculateRoute(data.stations);
}, },
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
); );
final isMobile = MediaQuery.of(context).size.width < 600; return ScaleTransition(
return isMobile scale:
? [addButton, reverseButton, calculateButton] Tween<double>(begin: 0.94, end: 1.0).animate(curved),
: [reverseButton, addButton, calculateButton]; child: FadeTransition(opacity: animation, child: child),
})(), );
},
child: buildPrimaryAction(fullWidth: false),
),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
], ],
), ),
), ),

View File

@@ -8,6 +8,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'loco_timeline/timeline_grid.dart'; part 'loco_timeline/timeline_grid.dart';
part 'loco_timeline/event_editor.dart'; part 'loco_timeline/event_editor.dart';
@@ -17,25 +18,41 @@ class LocoTimelinePage extends StatefulWidget {
super.key, super.key,
required this.locoId, required this.locoId,
required this.locoLabel, required this.locoLabel,
this.forceShowPending = false,
}); });
final int locoId; final int locoId;
final String locoLabel; final String locoLabel;
final bool forceShowPending;
@override @override
State<LocoTimelinePage> createState() => _LocoTimelinePageState(); State<LocoTimelinePage> createState() => _LocoTimelinePageState();
} }
class _LocoTimelinePageState extends State<LocoTimelinePage> { class _LocoTimelinePageState extends State<LocoTimelinePage> {
static const String _prefsKeyShowPending = 'timeline_show_pending';
final List<_EventDraft> _draftEvents = []; final List<_EventDraft> _draftEvents = [];
bool _isSaving = false; bool _isSaving = false;
bool _isDeleting = false; bool _isDeleting = false;
bool _isModerating = false; final Set<int> _moderatingEventIds = {};
final Set<String> _expandedPendingAttrs = {};
bool _showPending = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.forceShowPending) {
setState(() {
_showPending = true;
});
} else {
await _restorePendingVisibility();
}
if (!mounted) return;
await _load();
});
} }
dynamic _normalizeFieldValue(_FieldEntry field) { dynamic _normalizeFieldValue(_FieldEntry field) {
@@ -65,10 +82,35 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
data.fetchEventFields(); data.fetchEventFields();
return data.fetchLocoTimeline( return data.fetchLocoTimeline(
widget.locoId, widget.locoId,
includeAllPending: auth.isElevated, includeAllPending: auth.isElevated && _showPending,
); );
} }
Future<void> _restorePendingVisibility() async {
final auth = context.read<AuthService>();
if (!auth.isElevated) return;
try {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getBool(_prefsKeyShowPending);
if (saved == null) return;
if (!mounted) return;
setState(() {
_showPending = saved;
});
} catch (_) {
// Ignore preference restore failures.
}
}
Future<void> _persistPendingVisibility(bool value) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyShowPending, value);
} catch (_) {
// Ignore persistence failures.
}
}
void _addDraftEvent() { void _addDraftEvent() {
setState(() { setState(() {
_draftEvents.add(_EventDraft()); _draftEvents.add(_EventDraft());
@@ -247,7 +289,6 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
) async { ) async {
if (_isModerating) return;
final eventId = entry.sourceEventId; final eventId = entry.sourceEventId;
if (eventId == null) { if (eventId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -257,6 +298,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
); );
return; return;
} }
if (_moderatingEventIds.contains(eventId)) return;
final data = context.read<DataService>(); final data = context.read<DataService>();
final approve = action == _PendingModerationAction.approve; final approve = action == _PendingModerationAction.approve;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
@@ -283,7 +325,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
setState(() { setState(() {
_isModerating = true; _moderatingEventIds.add(eventId);
}); });
try { try {
if (approve) { if (approve) {
@@ -310,7 +352,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isModerating = false; _moderatingEventIds.remove(eventId);
}); });
} }
} }
@@ -499,7 +541,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId); final timeline = data.timelineForLoco(widget.locoId);
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
final isLoading = data.isLocoTimelineLoading(widget.locoId); final isLoading = data.isLocoTimelineLoading(widget.locoId);
final visibleTimeline = (!isElevated || _showPending)
? timeline
: timeline.where((entry) => !entry.isPending).toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -516,7 +562,32 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (isLoading && timeline.isEmpty) { if (isLoading && timeline.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (timeline.isEmpty) { if (visibleTimeline.isEmpty) {
if (timeline.isNotEmpty && isElevated && !_showPending) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pending entries hidden',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'Enable "Show pending entries" to view pending timeline blocks.',
),
],
),
),
),
);
}
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Card( child: Card(
@@ -550,15 +621,49 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (isElevated)
Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Show pending entries'),
value: _showPending,
onChanged: (value) async {
setState(() {
_showPending = value;
});
await _persistPendingVisibility(value);
if (mounted) {
await _load();
}
},
),
),
IconButton(
tooltip: 'Refresh timeline',
onPressed: _load,
icon: const Icon(Icons.refresh),
),
],
),
_TimelineGrid( _TimelineGrid(
entries: timeline, entries: visibleTimeline,
onEditEntry: (entry) => _prefillDraftFromEntry( onEditEntry: (entry) => _prefillDraftFromEntry(
entry, entry,
data.eventFields, data.eventFields,
), ),
onDeleteEntry: _deleteEntry, onDeleteEntry: _deleteEntry,
onModeratePending: _moderatePendingEntry, onModeratePending: _moderatePendingEntry,
pendingActionsBusy: _isModerating, pendingActionEventIds: _moderatingEventIds,
expandedPendingAttrs: _expandedPendingAttrs,
onTogglePendingAttr: (attrCode) {
setState(() {
if (!_expandedPendingAttrs.add(attrCode)) {
_expandedPendingAttrs.remove(attrCode);
}
});
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_EventEditor( _EventEditor(

View File

@@ -10,7 +10,9 @@ class _TimelineGrid extends StatefulWidget {
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending, this.onModeratePending,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
this.expandedPendingAttrs = const {},
this.onTogglePendingAttr,
}); });
final List<LocoAttrVersion> entries; final List<LocoAttrVersion> entries;
@@ -20,7 +22,9 @@ class _TimelineGrid extends StatefulWidget {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
)? onModeratePending; )? onModeratePending;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
final Set<String> expandedPendingAttrs;
final void Function(String attrCode)? onTogglePendingAttr;
@override @override
State<_TimelineGrid> createState() => _TimelineGridState(); State<_TimelineGrid> createState() => _TimelineGridState();
@@ -86,12 +90,15 @@ class _TimelineGridState extends State<_TimelineGrid> {
'build_day', 'build_day',
}.contains(code); }.contains(code);
}).toList(); }).toList();
final model = _TimelineModel.fromEntries(filteredEntries); final model = _TimelineModel.fromEntries(
filteredEntries,
expandedAttrCodes: widget.expandedPendingAttrs,
);
final axisSegments = model.axisSegments; final axisSegments = model.axisSegments;
const labelWidth = 110.0; const labelWidth = 110.0;
const rowHeight = 52.0; const rowHeight = 52.0;
const double axisHeight = 48; const double axisHeight = 48;
final rows = model.attrRows.entries.toList(); final rows = model.rows;
final totalRowsHeight = rows.length * rowHeight; final totalRowsHeight = rows.length * rowHeight;
final axisWidth = math.max(model.axisTotalWidth, 120.0); final axisWidth = math.max(model.axisTotalWidth, 120.0);
final double viewHeight = totalRowsHeight + axisHeight + 8; final double viewHeight = totalRowsHeight + axisHeight + 8;
@@ -131,7 +138,12 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight, itemExtent: rowHeight,
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final label = _formatAttrLabel(rows[index].key); final row = rows[index];
final label = row.isPrimary
? _formatAttrLabel(row.attrCode)
: (row.pendingUser?.trim().isNotEmpty == true
? row.pendingUser!.trim()
: 'Unknown');
return Container( return Container(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -148,12 +160,49 @@ class _TimelineGridState extends State<_TimelineGrid> {
), ),
), ),
), ),
child: Row(
children: [
if (!row.isPrimary) ...[
Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Theme.of(context).hintColor,
),
const SizedBox(width: 6),
],
Expanded(
child: Text( child: Text(
label, label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.labelLarge .labelLarge
?.copyWith(fontWeight: FontWeight.w700), ?.copyWith(
fontWeight: row.isPrimary
? FontWeight.w700
: FontWeight.w600,
),
),
),
if (row.showExpandToggle)
IconButton(
onPressed: widget.onTogglePendingAttr == null
? null
: () => widget.onTogglePendingAttr?.call(
row.attrCode,
),
icon: Icon(
row.isExpanded
? Icons.expand_less
: Icons.expand_more,
),
tooltip: row.isExpanded
? 'Collapse pending rows'
: 'Expand pending rows',
visualDensity: VisualDensity.compact,
),
],
), ),
); );
}, },
@@ -188,7 +237,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight, itemExtent: rowHeight,
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final blocks = rows[index].value; final blocks = rows[index].blocks;
return Padding( return Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 2.0), const EdgeInsets.symmetric(vertical: 2.0),
@@ -201,7 +250,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
onEditEntry: widget.onEditEntry, onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry, onDeleteEntry: widget.onDeleteEntry,
onModeratePending: widget.onModeratePending, onModeratePending: widget.onModeratePending,
pendingActionsBusy: widget.pendingActionsBusy, pendingActionEventIds: widget.pendingActionEventIds,
), ),
); );
}, },
@@ -288,7 +337,7 @@ class _AttrRow extends StatelessWidget {
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending, this.onModeratePending,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
}); });
final double rowHeight; final double rowHeight;
@@ -302,7 +351,7 @@ class _AttrRow extends StatelessWidget {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
)? onModeratePending; )? onModeratePending;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -329,7 +378,7 @@ class _AttrRow extends StatelessWidget {
onEditEntry: onEditEntry, onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry, onDeleteEntry: onDeleteEntry,
onModeratePending: onModeratePending, onModeratePending: onModeratePending,
pendingActionsBusy: pendingActionsBusy, pendingActionEventIds: pendingActionEventIds,
), ),
), ),
if (activeBlock != null) if (activeBlock != null)
@@ -346,7 +395,7 @@ class _AttrRow extends StatelessWidget {
width: stickyWidth, width: stickyWidth,
), ),
clipLeftEdge: scrollOffset > activeBlock.left + 0.1, clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
pendingActionsBusy: pendingActionsBusy, pendingActionEventIds: pendingActionEventIds,
), ),
), ),
), ),
@@ -368,12 +417,12 @@ class _ValueBlockView extends StatelessWidget {
const _ValueBlockView({ const _ValueBlockView({
required this.block, required this.block,
this.clipLeftEdge = false, this.clipLeftEdge = false,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
}); });
final _ValueBlock block; final _ValueBlock block;
final bool clipLeftEdge; final bool clipLeftEdge;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -384,6 +433,11 @@ class _ValueBlockView extends StatelessWidget {
? Colors.white ? Colors.white
: Colors.black87; : Colors.black87;
final entry = block.entry;
final eventId = entry?.sourceEventId;
final isPendingAction =
entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId);
final radius = BorderRadius.only( final radius = BorderRadius.only(
topLeft: Radius.circular(clipLeftEdge ? 0 : 12), topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
@@ -425,7 +479,7 @@ class _ValueBlockView extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: pendingActionsBusy child: isPendingAction
? CircularProgressIndicator( ? CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: valueColor:
@@ -484,7 +538,7 @@ class _ValueBlockMenu extends StatelessWidget {
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending, this.onModeratePending,
this.pendingActionsBusy = false, this.pendingActionEventIds = const {},
}); });
final _ValueBlock block; final _ValueBlock block;
@@ -494,7 +548,7 @@ class _ValueBlockMenu extends StatelessWidget {
LocoAttrVersion entry, LocoAttrVersion entry,
_PendingModerationAction action, _PendingModerationAction action,
)? onModeratePending; )? onModeratePending;
final bool pendingActionsBusy; final Set<int> pendingActionEventIds;
bool get _hasActions { bool get _hasActions {
final canModerate = block.entry?.isPending == true && final canModerate = block.entry?.isPending == true &&
@@ -515,6 +569,9 @@ class _ValueBlockMenu extends StatelessWidget {
block.entry?.canModeratePending == true && block.entry?.canModeratePending == true &&
onModeratePending != null; onModeratePending != null;
final canEdit = onEditEntry != null && block.entry?.isPending != true; final canEdit = onEditEntry != null && block.entry?.isPending != true;
final eventId = block.entry?.sourceEventId;
final isPendingAction =
eventId != null && pendingActionEventIds.contains(eventId);
Future<void> showContextMenuAt(Offset globalPosition) async { Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context); final overlay = Overlay.of(context);
@@ -540,13 +597,13 @@ class _ValueBlockMenu extends StatelessWidget {
if (canModerate) if (canModerate)
PopupMenuItem( PopupMenuItem(
value: _TimelineBlockAction.approve, value: _TimelineBlockAction.approve,
enabled: !pendingActionsBusy, enabled: !isPendingAction,
child: const Text('Approve pending'), child: const Text('Approve pending'),
), ),
if (canModerate) if (canModerate)
PopupMenuItem( PopupMenuItem(
value: _TimelineBlockAction.reject, value: _TimelineBlockAction.reject,
enabled: !pendingActionsBusy, enabled: !isPendingAction,
child: const Text('Reject pending'), child: const Text('Reject pending'),
), ),
if (onDeleteEntry != null) if (onDeleteEntry != null)
@@ -588,7 +645,7 @@ class _ValueBlockMenu extends StatelessWidget {
}, },
child: _ValueBlockView( child: _ValueBlockView(
block: block, block: block,
pendingActionsBusy: pendingActionsBusy, pendingActionEventIds: pendingActionEventIds,
), ),
); );
} }
@@ -649,47 +706,43 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
return end; return end;
} }
class _TimelineModel { int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day;
final List<_AxisSegment> axisSegments;
final Map<String, List<_ValueBlock>> attrRows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({ bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
required this.axisSegments, final start = _effectiveStart(entry);
required this.attrRows, if (start == null) return false;
required this.endLabel, return approvedStartKeys.contains(_startKey(start));
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in effectiveEntries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
} }
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) { List<_ValueSegment> _segmentsForEntries(
items.sort( List<LocoAttrVersion> items,
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) (a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now), .compareTo(_effectiveStart(b) ?? now),
); );
final segments = <_ValueSegment>[]; final segments = <_ValueSegment>[];
for (int i = 0; i < items.length; i++) { for (int i = 0; i < sorted.length; i++) {
final entry = items[i]; final entry = sorted[i];
final start = _effectiveStart(entry) ?? now; final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1 final nextStart = i < sorted.length - 1
? _effectiveStart(items[i + 1]) ? _effectiveStart(sorted[i + 1])
: null; : 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); final end = _safeEnd(start, rawEnd);
segments.add( segments.add(
_ValueSegment( _ValueSegment(
@@ -699,29 +752,264 @@ class _TimelineModel {
entry: entry, entry: entry,
), ),
); );
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
} }
attrSegments[attr] = segments; return segments;
}); }
minStart ??= now.subtract(const Duration(days: 1)); List<LocoAttrVersion> _applyPendingOverrides(
final effectiveMaxEnd = maxEnd ?? now; 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,
) {
DateTime? minStart;
DateTime? maxEnd;
final boundaryDates = <DateTime>{}; final boundaryDates = <DateTime>{};
for (final segments in attrSegments.values) {
for (final seg in segments) { for (final seg in segments) {
boundaryDates.add(seg.start); boundaryDates.add(seg.start);
boundaryDates.add(seg.end); boundaryDates.add(seg.end);
minStart = minStart == null || seg.start.isBefore(minStart!)
? seg.start
: minStart;
maxEnd = maxEnd == null || seg.end.isAfter(maxEnd!) ? seg.end : maxEnd;
} }
} minStart ??= now.subtract(const Duration(days: 1));
final effectiveMaxEnd = maxEnd ?? now;
boundaryDates.add(effectiveMaxEnd); boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort(); var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) { if (boundaries.length < 2) {
boundaries = [minStart!, effectiveMaxEnd]; boundaries = [minStart!, effectiveMaxEnd];
} }
return boundaries;
}
class _TimelineRowSpec {
final String id;
final String attrCode;
final List<_ValueSegment> segments;
final bool isPrimary;
final bool showExpandToggle;
final bool isExpanded;
final String? userLabel;
const _TimelineRowSpec._({
required this.id,
required this.attrCode,
required this.segments,
required this.isPrimary,
required this.showExpandToggle,
required this.isExpanded,
this.userLabel,
});
factory _TimelineRowSpec.primary({
required String attrCode,
required List<_ValueSegment> segments,
required bool showExpandToggle,
required bool isExpanded,
}) {
return _TimelineRowSpec._(
id: attrCode,
attrCode: attrCode,
segments: segments,
isPrimary: true,
showExpandToggle: showExpandToggle,
isExpanded: isExpanded,
);
}
factory _TimelineRowSpec.pending({
required String attrCode,
required String userLabel,
required List<_ValueSegment> segments,
}) {
return _TimelineRowSpec._(
id: '$attrCode::$userLabel',
attrCode: attrCode,
segments: segments,
isPrimary: false,
showExpandToggle: false,
isExpanded: false,
userLabel: userLabel,
);
}
}
class _TimelineRowData {
final String id;
final String attrCode;
final List<_ValueBlock> blocks;
final bool isPrimary;
final bool showExpandToggle;
final bool isExpanded;
final String? pendingUser;
const _TimelineRowData({
required this.id,
required this.attrCode,
required this.blocks,
required this.isPrimary,
required this.showExpandToggle,
required this.isExpanded,
this.pendingUser,
});
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final List<_TimelineRowData> rows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({
required this.axisSegments,
required this.rows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(
List<LocoAttrVersion> entries, {
Set<String> expandedAttrCodes = const {},
}) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{};
final attrOrder = <String>[];
for (final entry in effectiveEntries) {
final key = entry.attrCode;
if (!grouped.containsKey(key)) {
attrOrder.add(key);
}
grouped.putIfAbsent(key, () => []).add(entry);
}
final now = DateTime.now();
final allSegments = <_ValueSegment>[];
final rowSpecs = <_TimelineRowSpec>[];
for (final attr in attrOrder) {
final items = grouped[attr] ?? const [];
final approved = items.where((e) => !e.isPending).toList();
final pending = items.where((e) => e.isPending).toList();
final approvedSegments = _segmentsForEntries(approved, now);
final approvedStartKeys = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
if (start == null) continue;
approvedStartKeys.add(_startKey(start));
}
final pendingByUser = <String, List<LocoAttrVersion>>{};
final overlapByUser = <String, List<LocoAttrVersion>>{};
for (final entry in pending) {
final user = (entry.suggestedBy ?? '').trim().isEmpty
? 'Unknown'
: entry.suggestedBy!.trim();
pendingByUser.putIfAbsent(user, () => []).add(entry);
final start = _effectiveStart(entry);
if (start == null) continue;
if (approvedStartKeys.contains(_startKey(start))) {
overlapByUser.putIfAbsent(user, () => []).add(entry);
}
}
final hasOverlap = overlapByUser.isNotEmpty;
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 =
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
final baseSegments = shouldShowPendingRows
? approvedSegments
: _segmentsForEntries(baseEntries, now);
rowSpecs.add(
_TimelineRowSpec.primary(
attrCode: attr,
segments: baseSegments,
showExpandToggle: canToggle,
isExpanded: isExpanded,
),
);
allSegments.addAll(baseSegments);
if (shouldShowPendingRows) {
final users = isExpanded
? pendingByUser.keys.toList()
: overlapByUser.keys.toList();
users.sort();
for (final user in users) {
final pendingEntries = isExpanded
? (pendingByUser[user] ?? const [])
: (overlapByUser[user] ?? const []);
if (pendingEntries.isEmpty) continue;
final appliedEntries =
_applyPendingOverrides(approved, pendingEntries);
final combinedSegments = _segmentsForEntries(appliedEntries, now);
rowSpecs.add(
_TimelineRowSpec.pending(
attrCode: attr,
userLabel: user,
segments: combinedSegments,
),
);
allSegments.addAll(combinedSegments);
}
}
}
final boundaries = _buildBoundaries(allSegments, now);
final axisSegments = <_AxisSegment>[]; final axisSegments = <_AxisSegment>[];
const double yearWidth = 240.0; const double yearWidth = 240.0;
@@ -745,10 +1033,10 @@ class _TimelineModel {
final axisTotalWidth = final axisTotalWidth =
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width); axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{}; final rows = <_TimelineRowData>[];
for (final entry in attrSegments.entries) { for (final spec in rowSpecs) {
final blocks = <_ValueBlock>[]; final blocks = <_ValueBlock>[];
for (final seg in entry.value) { for (final seg in spec.segments) {
final left = _positionForDate(seg.start, boundaries, axisSegments); final left = _positionForDate(seg.start, boundaries, axisSegments);
final right = _positionForDate(seg.end, boundaries, axisSegments); final right = _positionForDate(seg.end, boundaries, axisSegments);
final span = right - left; final span = right - left;
@@ -762,13 +1050,24 @@ class _TimelineModel {
), ),
); );
} }
attrRows[entry.key] = blocks; rows.add(
_TimelineRowData(
id: spec.id,
attrCode: spec.attrCode,
blocks: blocks,
isPrimary: spec.isPrimary,
showExpandToggle: spec.showExpandToggle,
isExpanded: spec.isExpanded,
pendingUser: spec.userLabel,
),
);
} }
final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; final endLabel =
boundaries.isNotEmpty ? _formatDate(boundaries.last) ?? 'Now' : 'Now';
return _TimelineModel( return _TimelineModel(
axisSegments: axisSegments, axisSegments: axisSegments,
attrRows: attrRows, rows: rows,
endLabel: endLabel, endLabel: endLabel,
boundaries: boundaries, boundaries: boundaries,
axisTotalWidth: axisTotalWidth, axisTotalWidth: axisTotalWidth,

View File

@@ -130,11 +130,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
int decimals = 1, int decimals = 1,
bool includeUnit = true, bool includeUnit = true,
}) { }) {
return units.format( return units.format(miles, decimals: decimals, includeUnit: includeUnit);
miles,
decimals: decimals,
includeUnit: includeUnit,
);
} }
String _manualMileageLabel(DistanceUnit unit) { String _manualMileageLabel(DistanceUnit unit) {
@@ -191,15 +187,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
double _milesFromInputWithUnit(DistanceUnit unit) { double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit) return DistanceFormatter(
.parseInputMiles(_mileageController.text.trim()) ?? unit,
).parseInputMiles(_mileageController.text.trim()) ??
0; 0;
} }
List<UserSummary> _friendsFromFriendships( List<UserSummary> _friendsFromFriendships(DataService data, String? selfId) {
DataService data,
String? selfId,
) {
final friends = <UserSummary>[]; final friends = <UserSummary>[];
for (final f in data.friendships) { for (final f in data.friendships) {
final other = _friendFromFriendship(f, selfId); final other = _friendFromFriendship(f, selfId);
@@ -300,9 +294,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (!mounted) return; if (!mounted) return;
final baseFriends = _friendsFromFriendships(data, auth.userId); final baseFriends = _friendsFromFriendships(data, auth.userId);
final initialSelectedIds = {..._shareUserIds}; final initialSelectedIds = {..._shareUserIds};
final initialSelectedUsers = { final initialSelectedUsers = {for (final u in _shareUsers) u.userId: u};
for (final u in _shareUsers) u.userId: u,
};
final result = await showModalBottomSheet<List<UserSummary>>( final result = await showModalBottomSheet<List<UserSummary>>(
context: context, context: context,
@@ -334,8 +326,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
searchError = null; searchError = null;
}); });
try { try {
final results = final results = await data.searchUsers(
await data.searchUsers(trimmed, friendsOnly: true); trimmed,
friendsOnly: true,
);
setModalState(() { setModalState(() {
searchResults = results; searchResults = results;
}); });
@@ -414,8 +408,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
itemCount: list.length, itemCount: list.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final user = list[index]; final user = list[index];
final isSelected = final isSelected = selectedIds.contains(
selectedIds.contains(user.userId); user.userId,
);
return CheckboxListTile( return CheckboxListTile(
value: isSelected, value: isSelected,
title: Text(user.displayName), title: Text(user.displayName),
@@ -481,8 +476,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (previousUnit == currentUnit) return; if (previousUnit == currentUnit) return;
final miles = _milesFromInputWithUnit(previousUnit); final miles = _milesFromInputWithUnit(previousUnit);
final nextText = DistanceFormatter(currentUnit) final nextText = DistanceFormatter(
.format(miles, decimals: 2, includeUnit: false); currentUnit,
).format(miles, decimals: 2, includeUnit: false);
_mileageController.text = nextText; _mileageController.text = nextText;
_lastDistanceUnit = currentUnit; _lastDistanceUnit = currentUnit;
} }
@@ -842,8 +838,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
final destination = json['leg_destination'] as String? ?? ''; final destination = json['leg_destination'] as String? ?? '';
final hasEndTime = endTime != null || endDelay != 0; final hasEndTime = endTime != null || endDelay != 0;
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? ''); final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
final destinationTime = final destinationTime = DateTime.tryParse(
DateTime.tryParse(json['leg_destination_time'] ?? ''); json['leg_destination_time'] ?? '',
);
final hasOriginTime = originTime != null; final hasOriginTime = originTime != null;
final hasDestinationTime = destinationTime != null; final hasDestinationTime = destinationTime != null;
@@ -860,8 +857,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime; _selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime); _selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime; _selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = _selectedDestinationTime = TimeOfDay.fromDateTime(
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime); destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime; _hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime; _hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual; _useManualMileage = useManual;
@@ -927,7 +925,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
); );
final tractionItems = _buildTractionFromApi( final tractionItems = _buildTractionFromApi(
entry.locos entry.locos
.map((l) => { .map(
(l) => {
"loco_id": l.id, "loco_id": l.id,
"type": l.type, "type": l.type,
"number": l.number, "number": l.number,
@@ -938,7 +937,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
"evn": l.evn, "evn": l.evn,
"alloc_pos": l.allocPos, "alloc_pos": l.allocPos,
"alloc_powering": l.powering ? 1 : 0, "alloc_powering": l.powering ? 1 : 0,
}) },
)
.toList(), .toList(),
); );
final beginDelay = entry.beginDelayMinutes ?? 0; final beginDelay = entry.beginDelayMinutes ?? 0;
@@ -963,8 +963,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime; _selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime); _selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime; _selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = _selectedDestinationTime = TimeOfDay.fromDateTime(
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime); destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime; _hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime; _hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual; _useManualMileage = useManual;
@@ -980,12 +981,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
_endDelayController.text = endDelay.toString(); _endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0 _mileageController.text = mileageVal == 0
? '' ? ''
: _formatDistance( : _formatDistance(units, mileageVal, decimals: 2, includeUnit: false);
units,
mileageVal,
decimals: 2,
includeUnit: false,
);
_tractionItems _tractionItems
..clear() ..clear()
..addAll(tractionItems); ..addAll(tractionItems);
@@ -1187,10 +1183,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
), ),
], ],
if (!matchValue) ...[ if (!matchValue) ...[
_stationField( _stationField(label: label, controller: controller),
label: label,
controller: controller,
),
CheckboxListTile( CheckboxListTile(
value: hasTime, value: hasTime,
onChanged: onTimeChanged, onChanged: onTimeChanged,
@@ -1237,8 +1230,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: onPressed: _isEditing || _activeLegShare != null
_isEditing || _activeLegShare != null ? null : _openDrafts, ? null
: _openDrafts,
icon: const Icon(Icons.list_alt, size: 16), icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'), label: const Text('Drafts'),
), ),
@@ -1249,7 +1243,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: _isEditing || onPressed:
_isEditing ||
_savingDraft || _savingDraft ||
_submitting || _submitting ||
_activeLegShare != null _activeLegShare != null
@@ -1393,8 +1388,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
onTimeChanged: _submitting ? null : _toggleDestinationTime, onTimeChanged: _submitting ? null : _toggleDestinationTime,
matchLabel: 'Match entry end', matchLabel: 'Match entry end',
matchValue: _matchDestinationToEntry, matchValue: _matchDestinationToEntry,
onMatchChanged: onMatchChanged: _submitting ? null : _toggleMatchDestination,
_submitting ? null : _toggleMatchDestination,
pickerBuilder: () => _dateTimeGroupSimple( pickerBuilder: () => _dateTimeGroupSimple(
context, context,
title: 'Destination arrival', title: 'Destination arrival',
@@ -1428,7 +1422,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final tractionPanel = _section('Traction', [ final tractionPanel = _section('Traction', [
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: ElevatedButton.icon( child: FilledButton.icon(
onPressed: _openTractionPicker, onPressed: _openTractionPicker,
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
label: const Text('Search traction'), label: const Text('Search traction'),
@@ -1456,12 +1450,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton.icon( FilledButton.icon(
onPressed: _openCalculator, onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18), icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'), label: const Text('Open mileage calculator'),
), ),
OutlinedButton.icon( TextButton.icon(
onPressed: _reverseRouteAndEndpoints, onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz), icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'), label: const Text('Reverse route'),
@@ -1493,8 +1487,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
), ),
decoration: InputDecoration( decoration: InputDecoration(
labelText: mileageLabel, labelText: mileageLabel,
helperText: currentDistanceUnit == helperText:
DistanceUnit.milesChains currentDistanceUnit == DistanceUnit.milesChains
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)' ? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
: null, : null,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
@@ -1571,7 +1565,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton.icon( FilledButton.icon(
onPressed: _submitting ? null : _submit, onPressed: _submitting ? null : _submit,
icon: _submitting icon: _submitting
? const SizedBox( ? const SizedBox(
@@ -1842,7 +1836,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
return best; return best;
} }
void _insertCandidate(List<String> best, String candidate, {required int max}) { void _insertCandidate(
List<String> best,
String candidate, {
required int max,
}) {
final existingIndex = best.indexOf(candidate); final existingIndex = best.indexOf(candidate);
if (existingIndex >= 0) return; if (existingIndex >= 0) return;
@@ -2016,7 +2014,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
], ],
); );
} }
} }
class _UpperCaseTextFormatter extends TextInputFormatter { class _UpperCaseTextFormatter extends TextInputFormatter {

View File

@@ -4,6 +4,7 @@ enum _TractionMoreAction {
classStats, classStats,
classLeaderboard, classLeaderboard,
adminPending, adminPending,
adminPendingChanges,
} }
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
@@ -12,7 +13,9 @@ class TractionPage extends StatefulWidget {
this.selectionMode = false, this.selectionMode = false,
this.selectionSingle = false, this.selectionSingle = false,
this.replacementPendingLocoId, this.replacementPendingLocoId,
this.transferFromLabel,
this.transferFromLocoId, this.transferFromLocoId,
this.transferAllAllocations = false,
this.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
}); });
@@ -20,7 +23,9 @@ class TractionPage extends StatefulWidget {
final bool selectionMode; final bool selectionMode;
final bool selectionSingle; final bool selectionSingle;
final int? replacementPendingLocoId; final int? replacementPendingLocoId;
final String? transferFromLabel;
final int? transferFromLocoId; final int? transferFromLocoId;
final bool transferAllAllocations;
final ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -36,6 +41,12 @@ class _TractionPageState extends State<TractionPage> {
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId; int? get _transferFromLocoId => widget.transferFromLocoId;
bool get _transferAllAllocations {
if (widget.transferAllAllocations) return true;
final param =
GoRouterState.of(context).uri.queryParameters['transferAll'];
return param?.toLowerCase() == 'true' || param == '1';
}
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
@@ -61,6 +72,8 @@ class _TractionPageState extends State<TractionPage> {
static const int _pageSize = 100; static const int _pageSize = 100;
int _lastTractionOffset = 0; int _lastTractionOffset = 0;
String? _lastQuerySignature; String? _lastQuerySignature;
String? _transferFromLabel;
bool _isSearching = false;
@override @override
void initState() { void initState() {
@@ -74,6 +87,7 @@ class _TractionPageState extends State<TractionPage> {
if (!_initialised) { if (!_initialised) {
_initialised = true; _initialised = true;
_selectedKeys = {...widget.selectedKeys}; _selectedKeys = {...widget.selectedKeys};
_transferFromLabel = widget.transferFromLabel;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_initialLoad(); _initialLoad();
}); });
@@ -82,12 +96,16 @@ class _TractionPageState extends State<TractionPage> {
Future<void> _initialLoad() async { Future<void> _initialLoad() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final auth = context.read<AuthService>();
await _restoreSearchState(); await _restoreSearchState();
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) { if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
_lastTractionOffset = data.traction.length - _pageSize; _lastTractionOffset = data.traction.length - _pageSize;
} }
data.fetchClassList(); data.fetchClassList();
data.fetchEventFields(); data.fetchEventFields();
if (auth.isElevated) {
unawaited(data.fetchPendingLocoCount());
}
await _refreshTraction(); await _refreshTraction();
} }
@@ -153,6 +171,7 @@ class _TractionPageState extends State<TractionPage> {
bool append = false, bool append = false,
bool preservePosition = true, bool preservePosition = true,
}) async { }) async {
_setState(() => _isSearching = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
final filters = <String, dynamic>{}; final filters = <String, dynamic>{};
final name = _nameController.text.trim(); final name = _nameController.text.trim();
@@ -211,6 +230,7 @@ class _TractionPageState extends State<TractionPage> {
} }
await _persistSearchState(); await _persistSearchState();
_setState(() => _isSearching = false);
} }
void _clearFilters() { void _clearFilters() {
@@ -309,6 +329,7 @@ class _TractionPageState extends State<TractionPage> {
final isMobile = MediaQuery.of(context).size.width < 700; final isMobile = MediaQuery.of(context).size.width < 700;
_syncControllersForFields(data.eventFields); _syncControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields);
final transferBanner = _buildTransferBanner(data);
final slivers = <Widget>[ final slivers = <Widget>[
SliverPadding( SliverPadding(
@@ -338,6 +359,10 @@ class _TractionPageState extends State<TractionPage> {
_buildHeaderActions(context, isMobile), _buildHeaderActions(context, isMobile),
], ],
), ),
if (transferBanner != null) ...[
const SizedBox(height: 8),
transferBanner,
],
const SizedBox(height: 12), const SizedBox(height: 12),
Card( Card(
child: Padding( child: Padding(
@@ -487,8 +512,25 @@ class _TractionPageState extends State<TractionPage> {
), ),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _refreshTraction, onPressed: _refreshTraction,
icon: const Icon(Icons.search), icon: (_isSearching || data.isTractionLoading)
label: const Text('Search'), ? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context)
.colorScheme
.onPrimary,
),
),
)
: const Icon(Icons.search),
label: Text(
(_isSearching || data.isTractionLoading)
? 'Searching...'
: 'Search',
),
), ),
], ],
), ),
@@ -613,6 +655,12 @@ class _TractionPageState extends State<TractionPage> {
Widget _buildHeaderActions(BuildContext context, bool isMobile) { Widget _buildHeaderActions(BuildContext context, bool isMobile) {
final isElevated = context.read<AuthService>().isElevated; final isElevated = context.read<AuthService>().isElevated;
final data = context.watch<DataService>();
final pendingCount = data.pendingLocoCount;
String? pendingLabel;
if (pendingCount > 0) {
pendingLabel = pendingCount > 999 ? '999+' : pendingCount.toString();
}
final refreshButton = IconButton( final refreshButton = IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: _refreshTraction, onPressed: _refreshTraction,
@@ -659,6 +707,7 @@ class _TractionPageState extends State<TractionPage> {
try { try {
await context.push('/traction/pending'); await context.push('/traction/pending');
if (!mounted) return; if (!mounted) return;
await data.fetchPendingLocoCount();
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
@@ -668,23 +717,56 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
break; 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) { itemBuilder: (context) {
final items = <PopupMenuEntry<_TractionMoreAction>>[]; final items = <PopupMenuEntry<_TractionMoreAction>>[];
if (hasClassActions) { if (hasClassActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.classStats, value: _TractionMoreAction.classStats,
child: Text('Class stats'), child: Row(
children: [
Icon(
_showClassStatsPanel ? Icons.check : Icons.check_box_outline_blank,
size: 18,
),
const SizedBox(width: 8),
const Text('Class stats'),
],
),
), ),
); );
} }
if (hasClassActions) { if (hasClassActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.classLeaderboard, value: _TractionMoreAction.classLeaderboard,
child: Text('Class leaderboard'), child: Row(
children: [
Icon(
_showClassLeaderboardPanel
? Icons.check
: Icons.check_box_outline_blank,
size: 18,
),
const SizedBox(width: 8),
const Text('Class leaderboard'),
],
),
), ),
); );
} }
@@ -693,9 +775,22 @@ class _TractionPageState extends State<TractionPage> {
} }
if (hasAdminActions) { if (hasAdminActions) {
items.add( items.add(
const PopupMenuItem( PopupMenuItem(
value: _TractionMoreAction.adminPending, value: _TractionMoreAction.adminPending,
child: Text('Pending locos'), child: Row(
children: [
const Text('Pending locos'),
const Spacer(),
if (pendingLabel != null)
_countChip(context, pendingLabel),
],
),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPendingChanges,
child: Text('Pending changes'),
), ),
); );
} }
@@ -705,7 +800,16 @@ class _TractionPageState extends State<TractionPage> {
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: () {}, onPressed: () {},
icon: const Icon(Icons.more_horiz), icon: const Icon(Icons.more_horiz),
label: const Text('More'), label: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('More'),
if (pendingLabel != null) ...[
const SizedBox(width: 6),
_countChip(context, pendingLabel),
],
],
),
), ),
), ),
); );
@@ -1208,13 +1312,41 @@ class _TractionPageState extends State<TractionPage> {
if (fromId == null) return; if (fromId == null) return;
final navContext = context; final navContext = context;
final messenger = ScaffoldMessenger.of(navContext); final messenger = ScaffoldMessenger.of(navContext);
final data = navContext.read<DataService>();
final fromLoco = data.traction.firstWhere(
(l) => l.id == fromId,
orElse: () => target,
);
final fromLabel = '${fromLoco.locoClass} ${fromLoco.number}'.trim();
final toLabel = '${target.locoClass} ${target.number}'.trim();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: navContext, context: navContext,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Transfer allocations?'), title: Text(
content: Text( _transferAllAllocations
'Transfer all allocations from this loco to ${target.locoClass} ${target.number}?', ? 'Transfer all allocations?'
: 'Transfer allocations?',
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_transferAllAllocations
? 'Transfer all user allocations from $fromLabel to $toLabel?'
: 'Transfer all allocations from $fromLabel to $toLabel?',
),
const SizedBox(height: 12),
Text(
'From: $fromLabel',
style: Theme.of(dialogContext)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w700),
),
Text('To: $toLabel'),
],
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -1233,10 +1365,23 @@ class _TractionPageState extends State<TractionPage> {
if (!navContext.mounted) return; if (!navContext.mounted) return;
try { try {
final data = navContext.read<DataService>(); final data = navContext.read<DataService>();
if (_transferAllAllocations) {
await data.transferAllAllocations(
fromLocoId: fromId,
toLocoId: target.id,
);
} else {
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id); await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
}
if (navContext.mounted) { if (navContext.mounted) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Allocations transferred')), SnackBar(
content: Text(
_transferAllAllocations
? 'All allocations transferred'
: 'Allocations transferred',
),
),
); );
} }
await _refreshTraction(preservePosition: true); await _refreshTraction(preservePosition: true);
@@ -1273,6 +1418,71 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
Widget? _buildTransferBanner(DataService data) {
final fromId = _transferFromLocoId;
if (fromId == null) return null;
if (_transferFromLabel == null || _transferFromLabel!.trim().isEmpty) {
final from = data.traction.firstWhere(
(loco) => loco.id == fromId,
orElse: () => LocoSummary(
locoId: fromId,
locoType: '',
locoNumber: '',
locoName: '',
locoClass: '',
locoOperator: '',
),
);
final fallbackLabel = '${from.locoClass} ${from.number}'.trim().isEmpty
? 'Loco $fromId'
: '${from.locoClass} ${from.number}'.trim();
_transferFromLabel = fallbackLabel;
}
final label = _transferFromLabel ?? 'Loco $fromId';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.swap_horiz, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
_transferAllAllocations
? 'Transferring all allocations from $label. Select a loco to transfer to.'
: 'Transferring allocations from $label. Select a loco to transfer to.',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Widget _countChip(BuildContext context, String label) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
color: scheme.onPrimary,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
Widget _buildFilterInput( Widget _buildFilterInput(
BuildContext context, BuildContext context,
EventField field, EventField field,
@@ -1395,12 +1605,16 @@ class _TractionPageState extends State<TractionPage> {
onOpenLegs: () => _openLegs(loco), onOpenLegs: () => _openLegs(loco),
onActionComplete: _refreshTraction, onActionComplete: _refreshTraction,
onToggleSelect: widget.selectionMode && onToggleSelect: widget.selectionMode &&
widget.replacementPendingLocoId == null widget.replacementPendingLocoId == null &&
(widget.transferFromLocoId == null ||
widget.transferFromLocoId != loco.id)
? () => _toggleSelection(loco) ? () => _toggleSelection(loco)
: null, : null,
onReplacePending: widget.selectionMode && onReplacePending: widget.selectionMode &&
widget.selectionSingle && widget.selectionSingle &&
widget.replacementPendingLocoId != null widget.replacementPendingLocoId != null &&
(widget.transferFromLocoId == null ||
widget.transferFromLocoId != loco.id)
? () => _confirmReplacePending(loco) ? () => _confirmReplacePending(loco)
: null, : null,
onTransferAllocations: widget.selectionMode && onTransferAllocations: widget.selectionMode &&

View 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,
),
);
},
);
}
}

View File

@@ -635,27 +635,6 @@ Future<void> showTractionDetails(
child: ListView( child: ListView(
controller: controller, controller: controller,
children: [ children: [
FilledButton.icon(
onPressed: () {
Navigator.of(ctx).pop();
navContext.push(
Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
},
);
},
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
const SizedBox(height: 12),
if (isRejected && rejectedReason.isNotEmpty) if (isRejected && rejectedReason.isNotEmpty)
...[ ...[
_detailRow( _detailRow(
@@ -745,20 +724,27 @@ Future<void> showTractionDetails(
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (hasMileageOrTrips)
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push( navContext.push(
Uri( Uri(
path: '/traction', path: '/traction',
queryParameters: { queryParameters: {
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id.toString(), 'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
'transferAll': '0',
}, },
).toString(), ).toString(),
extra: { extra: {
'selection': 'single', 'selection': 'single',
'transferFromLocoId': loco.id, 'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': false,
}, },
); );
}, },
@@ -775,6 +761,34 @@ Future<void> showTractionDetails(
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
children: [ children: [
if (auth.isElevated) ...[
FilledButton.tonal(
onPressed: () {
Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push(
Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
'transferAll': 'true',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': true,
},
);
},
child: const Text('Transfer all allocations'),
),
const SizedBox(height: 8),
],
FilledButton.tonal( FilledButton.tonal(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: backgroundColor:

View File

@@ -19,6 +19,8 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
int? _legId; int? _legId;
int? _shareId; int? _shareId;
Leg? _currentLeg; Leg? _currentLeg;
LegShareData? _share;
Future<LegShareData?>? _shareFuture;
bool _loading = false; bool _loading = false;
static const int _summaryLimit = 3; static const int _summaryLimit = 3;
@@ -33,11 +35,11 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) { void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.notification != widget.notification) { if (oldWidget.notification != widget.notification) {
_parseNotification(); _parseNotification(notify: true);
} }
} }
void _parseNotification() { void _parseNotification({bool notify = false}) {
final rawBody = widget.notification.body.trim(); final rawBody = widget.notification.body.trim();
// Reset // Reset
@@ -45,11 +47,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
_legId = null; _legId = null;
_currentLeg = null; _currentLeg = null;
_changes = null; _changes = null;
_share = null;
_shareFuture = null;
final parsed = _decodeBody(rawBody); final parsed = _decodeBody(rawBody);
if (parsed != null) { if (parsed != null) {
_shareId = _parseInt(parsed['share_id']); _shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']); _legId = _parseInt(parsed['leg_id']);
_currentLeg = _findCurrentLeg(_legId);
final accepted = _asStringKeyedMap(parsed['accepted_changes']); final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) { if (accepted != null) {
_changes = accepted; _changes = accepted;
@@ -58,6 +63,10 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
// Fallback: extract share_id from raw string if still missing. // Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody); _shareId ??= _extractShareId(rawBody);
_prepareShareFuture();
if (notify) {
setState(() {});
}
} }
int? _parseInt(dynamic value) { int? _parseInt(dynamic value) {
@@ -164,6 +173,25 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
} }
} }
void _prepareShareFuture() {
if (_shareId == null) return;
_shareFuture = context.read<DataService>().fetchLegShare(_shareId!.toString());
}
Future<void> _loadShareIfNeeded() async {
if (_share != null) return;
if (_shareId == null) return;
try {
final future = _shareFuture ??
context.read<DataService>().fetchLegShare(_shareId!.toString());
final share = await future;
if (!mounted) return;
_share = share;
} catch (e) {
// ignore: avoid_empty_catches
}
}
Leg? _findCurrentLeg(int? legId) { Leg? _findCurrentLeg(int? legId) {
if (legId == null) return null; if (legId == null) return null;
final data = context.read<DataService>(); final data = context.read<DataService>();
@@ -174,6 +202,79 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
} }
} }
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year.toString().padLeft(4, '0')}-'
'${dateTime.month.toString().padLeft(2, '0')}-'
'${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:'
'${dateTime.minute.toString().padLeft(2, '0')}';
}
Widget _buildLegSummary(BuildContext context) {
final future = _shareFuture;
if (future == null) {
final leg = _currentLeg;
return leg == null ? const SizedBox.shrink() : _legSummaryRow(context, leg);
}
return FutureBuilder<LegShareData?>(
future: future,
builder: (context, snapshot) {
final share = snapshot.data;
if (share != null) {
_share = share;
}
final leg = share?.entry ?? _currentLeg;
if (leg == null) {
return const SizedBox.shrink();
}
return _legSummaryRow(context, leg);
},
);
}
Widget _legSummaryRow(BuildContext context, Leg leg) {
final start = leg.route.isNotEmpty ? leg.route.first : leg.start;
final end = leg.route.isNotEmpty ? leg.route.last : leg.end;
return Text('${_formatDateTime(leg.beginTime)}$start$end');
}
String _asString(dynamic value) {
if (value == null) return '';
return value.toString();
}
Loco? _resolveLocoById(int locoId, {Leg? shareLeg}) {
for (final loco in shareLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in _currentLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in context.read<DataService>().traction) {
if (loco.id == locoId) return loco;
}
return null;
}
String _locoDisplayName(Map<String, dynamic> loco, {Leg? shareLeg}) {
final locoId = _parseInt(loco['loco_id']);
var locoClass = _asString(loco['class'] ?? loco['loco_class']);
var number = _asString(loco['number'] ?? loco['loco_number']);
if ((locoClass.isEmpty || number.isEmpty) && locoId != null) {
final resolved = _resolveLocoById(locoId, shareLeg: shareLeg);
if (resolved != null) {
if (locoClass.isEmpty) locoClass = resolved.locoClass;
if (number.isEmpty) number = resolved.number;
}
}
final parts = <String>[];
if (locoClass.isNotEmpty) parts.add(locoClass);
if (number.isNotEmpty) parts.add(number);
if (parts.isNotEmpty) return parts.join(' ');
return 'Loco ${locoId ?? '?'}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -184,10 +285,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
final entries = changes.entries.toList(); final entries = changes.entries.toList();
final shown = entries.take(_summaryLimit).toList(); final shown = entries.take(_summaryLimit).toList();
final remaining = entries.length - shown.length; final remaining = entries.length - shown.length;
final legSummary = _buildLegSummary(context);
final hasSummary = _shareFuture != null || _currentLeg != null;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (hasSummary) legSummary,
if (hasSummary) const SizedBox(height: 8),
...shown.map((e) => _changePreview(context, e)), ...shown.map((e) => _changePreview(context, e)),
if (remaining > 0) if (remaining > 0)
Padding( Padding(
@@ -325,6 +430,7 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
Future<void> _openDrawer(Map<String, dynamic> changes) async { Future<void> _openDrawer(Map<String, dynamic> changes) async {
setState(() => _loading = true); setState(() => _loading = true);
await _loadLegIdIfNeeded(); await _loadLegIdIfNeeded();
await _loadShareIfNeeded();
_currentLeg ??= _findCurrentLeg(_legId); _currentLeg ??= _findCurrentLeg(_legId);
if (!mounted) return; if (!mounted) return;
setState(() => _loading = false); setState(() => _loading = false);
@@ -398,7 +504,9 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true) (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: Theme.of(context).colorScheme.surfaceContainerHighest, : Theme.of(context).colorScheme.surfaceContainerHighest,
label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'), label: Text(
'${_locoDisplayName(loco, shareLeg: _share?.entry)} (pos ${loco['alloc_pos'] ?? '?'})',
),
avatar: Icon( avatar: Icon(
Icons.train, Icons.train,
size: 16, size: 16,

View File

@@ -7,15 +7,23 @@ import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LegShareNotificationCard extends StatelessWidget { class LegShareNotificationCard extends StatefulWidget {
const LegShareNotificationCard({super.key, required this.notification}); const LegShareNotificationCard({super.key, required this.notification});
final UserNotification notification; final UserNotification notification;
@override
State<LegShareNotificationCard> createState() => _LegShareNotificationCardState();
}
class _LegShareNotificationCardState extends State<LegShareNotificationCard> {
bool _accepting = false;
bool _rejecting = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.read<DataService>(); final data = context.read<DataService>();
final legShareId = _extractLegShareId(notification.body); final legShareId = _extractLegShareId(widget.notification.body);
if (legShareId == null) { if (legShareId == null) {
return const Text('Invalid leg share notification.'); return const Text('Invalid leg share notification.');
} }
@@ -78,16 +86,28 @@ class LegShareNotificationCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton( ElevatedButton(
onPressed: () => _accept(context, share), onPressed: _accepting ? null : () => _accept(context, share),
child: const Text('Accept'), child: _accepting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Accept'),
), ),
OutlinedButton( OutlinedButton(
onPressed: () => _inspect(context, share), onPressed: () => _inspect(context, share),
child: const Text('Inspect'), child: const Text('Inspect'),
), ),
TextButton( TextButton(
onPressed: () => _reject(context, share), onPressed: _rejecting ? null : () => _reject(context, share),
child: const Text('Reject'), child: _rejecting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Reject'),
), ),
], ],
), ),
@@ -98,12 +118,13 @@ class LegShareNotificationCard extends StatelessWidget {
} }
Future<void> _accept(BuildContext context, LegShareData share) async { Future<void> _accept(BuildContext context, LegShareData share) async {
setState(() => _accepting = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
try { try {
await data.acceptLegShare(share); await data.acceptLegShare(share);
if (!context.mounted) return; if (!context.mounted) return;
await data.dismissNotifications([notification.id]); await data.dismissNotifications([widget.notification.id]);
// Refresh legs in the background. // Refresh legs in the background.
unawaited(data.refreshLegs()); unawaited(data.refreshLegs());
messenger?.showSnackBar( messenger?.showSnackBar(
@@ -113,16 +134,21 @@ class LegShareNotificationCard extends StatelessWidget {
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to add shared entry: $e')), SnackBar(content: Text('Failed to add shared entry: $e')),
); );
} finally {
if (mounted) {
setState(() => _accepting = false);
}
} }
} }
Future<void> _reject(BuildContext context, LegShareData share) async { Future<void> _reject(BuildContext context, LegShareData share) async {
setState(() => _rejecting = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
try { try {
await data.rejectLegShare(share.id); await data.rejectLegShare(share.id);
if (!context.mounted) return; if (!context.mounted) return;
await data.dismissNotifications([notification.id]); await data.dismissNotifications([widget.notification.id]);
messenger?.showSnackBar( messenger?.showSnackBar(
const SnackBar(content: Text('Share rejected')), const SnackBar(content: Text('Share rejected')),
); );
@@ -130,6 +156,10 @@ class LegShareNotificationCard extends StatelessWidget {
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to reject share: $e')), SnackBar(content: Text('Failed to reject share: $e')),
); );
} finally {
if (mounted) {
setState(() => _rejecting = false);
}
} }
} }
@@ -140,7 +170,7 @@ class LegShareNotificationCard extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final target = share.copyWith(notificationId: notification.id); final target = share.copyWith(notificationId: widget.notification.id);
final ts = DateTime.now().millisecondsSinceEpoch; final ts = DateTime.now().millisecondsSinceEpoch;
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts'; final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
router.go(path, extra: target); router.go(path, extra: target);

View File

@@ -52,6 +52,8 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
int _pendingLocoCount = 0;
int get pendingLocoCount => _pendingLocoCount;
List<LocoChange> _latestLocoChanges = []; List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges; List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false; bool _isLatestLocoChangesLoading = false;
@@ -94,6 +96,22 @@ class DataService extends ChangeNotifier {
bool _isEventFieldsLoading = false; bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading; bool get isEventFieldsLoading => _isEventFieldsLoading;
Future<void> fetchPendingLocoCount() async {
try {
final json = await api.get('/loco/pending?limit=1000&offset=0');
if (json is List) {
_pendingLocoCount = json.length;
} else {
_pendingLocoCount = 0;
}
} catch (e) {
debugPrint('Failed to fetch pending loco count: $e');
_pendingLocoCount = 0;
} finally {
_notifyAsync();
}
}
// Station Data // Station Data
final Map<String, List<Station>> _stationCache = {}; final Map<String, List<Station>> _stationCache = {};
final Map<String, Future<List<Station>>?> _stationInFlightByKey = {}; final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};

View File

@@ -64,6 +64,7 @@ extension DataServiceTraction on DataService {
_isTractionLoading = false; _isTractionLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline( Future<List<LocoAttrVersion>> fetchLocoTimeline(
@@ -481,6 +482,23 @@ extension DataServiceTraction on DataService {
} }
} }
Future<void> transferAllAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer?transferAll=true', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint(
'Failed to transfer all allocations $fromLocoId -> $toLocoId: $e',
);
rethrow;
}
}
Future<void> adminDeleteLoco({required int locoId}) async { Future<void> adminDeleteLoco({required int locoId}) async {
try { try {
await api.delete('/loco/admin/delete/$locoId'); await api.delete('/loco/admin/delete/$locoId');

View File

@@ -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/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.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_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/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
@@ -195,6 +196,26 @@ class _MyAppState extends State<MyApp> {
'', '',
) )
: null; : null;
final transferFromLabel = state.uri.queryParameters['transferFromLabel'] ??
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
bool transferAllAllocations = false;
final transferAllParam =
state.uri.queryParameters['transferAll'] ??
(state.extra is Map
? (state.extra as Map)['transferAll']?.toString()
: null);
if (transferAllParam != null) {
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
transferAllParam == '1';
}
if (!transferAllAllocations && state.extra is Map) {
final raw = (state.extra as Map)['transferAll'];
if (raw is bool) {
transferAllAllocations = raw;
}
}
final selectionMode = final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) || (selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null || replacementPendingLocoId != null ||
@@ -208,7 +229,9 @@ class _MyAppState extends State<MyApp> {
selectionMode: selectionMode, selectionMode: selectionMode,
selectionSingle: selectionSingle, selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId, transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
); );
}, },
), ),
@@ -216,6 +239,11 @@ class _MyAppState extends State<MyApp> {
path: '/traction/pending', path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(), builder: (context, state) => const TractionPendingPage(),
), ),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
@@ -233,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra; label = extra;
} }
if (label.trim().isEmpty) label = 'Loco $locoId'; 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( GoRoute(
@@ -518,10 +554,7 @@ class _MyHomePageState extends State<MyHomePage> {
) )
.toList(); .toList();
return Scaffold( final logo = Text.rich(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text.rich(
TextSpan( TextSpan(
children: const [ children: const [
TextSpan(text: "Mile"), TextSpan(text: "Mile"),
@@ -537,6 +570,17 @@ class _MyHomePageState extends State<MyHomePage> {
fontFamily: "Tomatoes", fontFamily: "Tomatoes",
), ),
), ),
);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: isWide
? logo
: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: logo,
), ),
actions: [ actions: [
_buildNotificationAction(context, data), _buildNotificationAction(context, data),

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.7.1+9 version: 0.7.4+12
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1