Compare commits

...

8 Commits

Author SHA1 Message Date
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
5b94ab263b move transfer button, hide delete button better
All checks were successful
Release / meta (push) Successful in 28s
Release / linux-build (push) Successful in 54s
Release / web-build (push) Successful in 4m48s
Release / android-build (push) Successful in 15m12s
Release / release-master (push) Successful in 31s
Release / release-dev (push) Successful in 34s
2026-01-06 12:04:34 +00:00
06bed86a49 Add accent colour picker, fix empty user card when accepting friend request, add button to transfer allocations
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 56s
Release / web-build (push) Successful in 2m15s
Release / android-build (push) Successful in 6m47s
Release / release-master (push) Successful in 19s
Release / release-dev (push) Successful in 21s
2026-01-06 00:21:19 +00:00
18 changed files with 1731 additions and 272 deletions

View File

@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.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:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -17,9 +19,15 @@ class App extends StatelessWidget {
ChangeNotifierProvider<EndpointService>( ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(), create: (_) => EndpointService(),
), ),
ChangeNotifierProvider<AccentColorService>(
create: (_) => AccentColorService(),
),
ChangeNotifierProvider<DistanceUnitService>( ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(), create: (_) => DistanceUnitService(),
), ),
ChangeNotifierProvider<ThemeModeService>(
create: (_) => ThemeModeService(),
),
ProxyProvider<EndpointService, ApiService>( ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) { update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl); final service = api ?? ApiService(baseUrl: endpoint.baseUrl);

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) { List<_ValueSegment> _segmentsForEntries(
final effectiveEntries = entries List<LocoAttrVersion> items,
.where((e) => _effectiveStart(e) != null) DateTime now, {
.toList(); bool? clampToNextStart,
final grouped = <String, List<LocoAttrVersion>>{}; }) {
for (final entry in effectiveEntries) { if (items.isEmpty) return const [];
grouped.putIfAbsent(entry.attrCode, () => []).add(entry); final hasPending = items.any((e) => e.isPending);
} final hasApproved = items.any((e) => !e.isPending);
final now = DateTime.now(); final shouldClamp =
DateTime? minStart; clampToNextStart ?? (hasPending && hasApproved);
DateTime? maxEnd; final sorted = [...items];
final attrSegments = <String, List<_ValueSegment>>{}; sorted.sort(
grouped.forEach((attr, items) {
items.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

@@ -127,6 +127,9 @@ class _ProfilePageState extends State<ProfilePage> {
); );
if (!mounted) return; if (!mounted) return;
setState(() => _status = status); setState(() => _status = status);
final data = context.read<DataService>();
await data.fetchFriendships();
await data.fetchPendingFriendships();
_showSnack('Friend request sent'); _showSnack('Friend request sent');
} catch (e) { } catch (e) {
_showSnack('Failed to send request: $e'); _showSnack('Failed to send request: $e');
@@ -159,6 +162,7 @@ class _ProfilePageState extends State<ProfilePage> {
final updated = await context.read<DataService>().acceptFriendship(id); final updated = await context.read<DataService>().acceptFriendship(id);
if (!mounted) return; if (!mounted) return;
setState(() => _status = updated); setState(() => _status = updated);
await context.read<DataService>().fetchFriendships();
_showSnack('Friend request accepted'); _showSnack('Friend request accepted');
} catch (e) { } catch (e) {
_showSnack('Failed to accept: $e'); _showSnack('Failed to accept: $e');
@@ -175,6 +179,7 @@ class _ProfilePageState extends State<ProfilePage> {
final updated = await context.read<DataService>().rejectFriendship(id); final updated = await context.read<DataService>().rejectFriendship(id);
if (!mounted) return; if (!mounted) return;
setState(() => _status = updated); setState(() => _status = updated);
await context.read<DataService>().fetchPendingFriendships();
_showSnack('Friend request rejected'); _showSnack('Friend request rejected');
} catch (e) { } catch (e) {
_showSnack('Failed to reject: $e'); _showSnack('Failed to reject: $e');

View File

@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.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';
@@ -18,6 +20,18 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController; late final TextEditingController _endpointController;
bool _saving = false; bool _saving = false;
static const List<Color> _accentPalette = [
Colors.red,
Colors.pink,
Colors.orange,
Colors.amber,
Colors.green,
Colors.teal,
Colors.blue,
Colors.indigo,
Colors.purple,
Colors.cyan,
];
@override @override
void initState() { void initState() {
@@ -130,7 +144,12 @@ class _SettingsPageState extends State<SettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>(); final endpointService = context.watch<EndpointService>();
final distanceUnitService = context.watch<DistanceUnitService>(); final distanceUnitService = context.watch<DistanceUnitService>();
if (!endpointService.isLoaded || !distanceUnitService.isLoaded) { final accentService = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
if (!endpointService.isLoaded ||
!distanceUnitService.isLoaded ||
!accentService.isLoaded ||
!themeModeService.isLoaded) {
return const Scaffold( return const Scaffold(
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
@@ -184,6 +203,73 @@ class _SettingsPageState extends State<SettingsPage> {
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text(
'Accent colour',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose your preferred accent colour or use system colours.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed:
accentService.useSystem ? null : () => accentService.setUseSystem(true),
icon: const Icon(Icons.phone_android),
label: const Text('Use system colours'),
),
..._accentPalette.map(
(color) => _AccentSwatchButton(
color: color,
selected:
!accentService.useSystem &&
accentService.seedColor.toARGB32() == color.toARGB32(),
onTap: () => accentService.setSeedColor(color),
),
),
],
),
const SizedBox(height: 24),
Text(
'Theme mode',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.settings_suggest),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeModeService.mode},
onSelectionChanged: (selection) {
final mode = selection.first;
themeModeService.setMode(mode);
},
),
const SizedBox(height: 24),
Text( Text(
'API endpoint', 'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -241,3 +327,54 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
} }
class _AccentSwatchButton extends StatelessWidget {
const _AccentSwatchButton({
required this.color,
required this.selected,
required this.onTap,
});
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final borderColor = selected
? Theme.of(context).colorScheme.onSurface
: Colors.black26;
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: selected ? 3 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: selected
? const Center(
child: Icon(
Icons.check,
size: 18,
color: Colors.white,
),
)
: null,
),
);
}
}

View File

@@ -4,6 +4,7 @@ enum _TractionMoreAction {
classStats, classStats,
classLeaderboard, classLeaderboard,
adminPending, adminPending,
adminPendingChanges,
} }
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
@@ -12,6 +13,8 @@ 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.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
}); });
@@ -19,6 +22,8 @@ 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 ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -33,6 +38,7 @@ class _TractionPageState extends State<TractionPage> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId;
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
@@ -58,6 +64,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() {
@@ -71,6 +79,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();
}); });
@@ -79,12 +88,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();
} }
@@ -150,6 +163,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();
@@ -208,6 +222,7 @@ class _TractionPageState extends State<TractionPage> {
} }
await _persistSearchState(); await _persistSearchState();
_setState(() => _isSearching = false);
} }
void _clearFilters() { void _clearFilters() {
@@ -306,6 +321,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(
@@ -335,6 +351,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(
@@ -484,8 +504,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',
),
), ),
], ],
), ),
@@ -610,6 +647,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,
@@ -656,6 +699,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(
@@ -665,23 +709,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'),
],
),
), ),
); );
} }
@@ -690,9 +767,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'),
), ),
); );
} }
@@ -702,7 +792,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),
],
],
),
), ),
), ),
); );
@@ -1200,6 +1299,75 @@ class _TractionPageState extends State<TractionPage> {
} }
} }
Future<void> _confirmTransfer(LocoSummary target) async {
final fromId = _transferFromLocoId;
if (fromId == null) return;
final navContext = context;
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>(
context: navContext,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Transfer allocations?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'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: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Transfer'),
),
],
);
},
);
if (confirmed != true) return;
if (!navContext.mounted) return;
try {
final data = navContext.read<DataService>();
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Allocations transferred')),
);
}
await _refreshTraction(preservePosition: true);
if (navContext.mounted) navContext.pop();
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to transfer allocations: $e')),
);
}
}
}
bool _isSelected(LocoSummary loco) { bool _isSelected(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}'; final keyVal = '${loco.locoClass}-${loco.number}';
return _selectedKeys.contains(keyVal); return _selectedKeys.contains(keyVal);
@@ -1223,6 +1391,69 @@ 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(
'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,
@@ -1345,14 +1576,24 @@ 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 &&
widget.selectionSingle &&
widget.transferFromLocoId != null &&
widget.transferFromLocoId != loco.id
? () => _confirmTransfer(loco)
: null,
); );
} }

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

@@ -20,6 +20,7 @@ class TractionCard extends StatelessWidget {
this.onToggleSelect, this.onToggleSelect,
this.onReplacePending, this.onReplacePending,
this.onActionComplete, this.onActionComplete,
this.onTransferAllocations,
}); });
final LocoSummary loco; final LocoSummary loco;
@@ -31,6 +32,7 @@ class TractionCard extends StatelessWidget {
final VoidCallback? onToggleSelect; final VoidCallback? onToggleSelect;
final VoidCallback? onReplacePending; final VoidCallback? onReplacePending;
final Future<void> Function()? onActionComplete; final Future<void> Function()? onActionComplete;
final VoidCallback? onTransferAllocations;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -145,7 +147,13 @@ class TractionCard extends StatelessWidget {
]; ];
// Prefer replace action when picking a replacement loco. // Prefer replace action when picking a replacement loco.
final addButton = onReplacePending != null final addButton = onTransferAllocations != null
? TextButton.icon(
onPressed: onTransferAllocations,
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer'),
)
: onReplacePending != null
? TextButton.icon( ? TextButton.icon(
onPressed: onReplacePending, onPressed: onReplacePending,
icon: const Icon(Icons.swap_horiz), icon: const Icon(Icons.swap_horiz),
@@ -159,7 +167,8 @@ class TractionCard extends StatelessWidget {
? Icons.remove_circle_outline ? Icons.remove_circle_outline
: Icons.add_circle_outline, : Icons.add_circle_outline,
), ),
label: Text(isSelected ? 'Remove' : 'Add to entry'), label:
Text(isSelected ? 'Remove' : 'Add to entry'),
) )
: null; : null;
@@ -551,6 +560,7 @@ Future<void> showTractionDetails(
LocoSummary loco, { LocoSummary loco, {
Future<void> Function()? onActionComplete, Future<void> Function()? onActionComplete,
}) async { }) async {
final navContext = context;
final hasMileageOrTrips = _hasMileageOrTrips(loco); final hasMileageOrTrips = _hasMileageOrTrips(loco);
final isVisibilityPending = final isVisibilityPending =
(loco.visibility ?? '').toLowerCase().trim() == 'pending'; (loco.visibility ?? '').toLowerCase().trim() == 'pending';
@@ -713,8 +723,40 @@ Future<void> showTractionDetails(
); );
}, },
), ),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon(
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,
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
},
);
},
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: Text(
'More',
style: Theme.of(context).textTheme.titleSmall,
),
children: [
FilledButton.tonal( FilledButton.tonal(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: backgroundColor:
@@ -733,11 +775,13 @@ Future<void> showTractionDetails(
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () =>
Navigator.of(context).pop(false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () =>
Navigator.of(context).pop(true),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: backgroundColor:
Theme.of(context).colorScheme.error, Theme.of(context).colorScheme.error,
@@ -759,12 +803,16 @@ Future<void> showTractionDetails(
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
} catch (e) { } catch (e) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text('Failed to delete loco: $e')), SnackBar(
content: Text('Failed to delete loco: $e')),
); );
} }
}, },
child: const Text('Delete loco'), child: const Text('Delete loco'),
), ),
const SizedBox(height: 8),
],
),
], ],
], ],
), ),

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

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AccentColorService extends ChangeNotifier {
static const _prefsKeyUseSystem = 'accent_use_system';
static const _prefsKeySeed = 'accent_seed';
static const Color defaultSeed = Colors.red;
bool _useSystem = true;
Color _seedColor = defaultSeed;
bool _hasSavedSeed = false;
bool _loaded = false;
bool get useSystem => _useSystem;
Color get seedColor => _seedColor;
bool get hasSavedSeed => _hasSavedSeed;
bool get isLoaded => _loaded;
AccentColorService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
_useSystem = prefs.getBool(_prefsKeyUseSystem) ?? true;
final seedValue = prefs.getInt(_prefsKeySeed);
if (seedValue != null) {
_seedColor = Color(seedValue);
_hasSavedSeed = true;
}
_loaded = true;
notifyListeners();
}
Future<void> setUseSystem(bool value) async {
_useSystem = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
Future<void> setSeedColor(Color color) async {
_seedColor = color;
_useSystem = false;
_hasSavedSeed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefsKeySeed, color.toARGB32());
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
}

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

@@ -151,6 +151,8 @@ extension DataServiceFriendships on DataService {
overrideAddressee: targetUser, overrideAddressee: targetUser,
); );
_pendingOutgoing = [friendship, ..._pendingOutgoing]; _pendingOutgoing = [friendship, ..._pendingOutgoing];
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
return friendship; return friendship;
} }
@@ -159,6 +161,8 @@ extension DataServiceFriendships on DataService {
final json = await api.post('/friendships/$friendshipId/accept', {}); final json = await api.post('/friendships/$friendshipId/accept', {});
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted'); final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted');
_pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList(); _pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
return friendship; return friendship;
} }
@@ -177,6 +181,8 @@ extension DataServiceFriendships on DataService {
_parseAndRemoveFriendship(json, friendshipId, status: 'none'); _parseAndRemoveFriendship(json, friendshipId, status: 'none');
_pendingOutgoing = _pendingOutgoing =
_pendingOutgoing.where((f) => f.id != friendshipId).toList(); _pendingOutgoing.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
return friendship; return friendship;
} }
@@ -193,6 +199,8 @@ extension DataServiceFriendships on DataService {
_pendingIncoming.where((f) => f.id != friendshipId).toList(); _pendingIncoming.where((f) => f.id != friendshipId).toList();
_pendingOutgoing = _pendingOutgoing =
_pendingOutgoing.where((f) => f.id != friendshipId).toList(); _pendingOutgoing.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync(); _notifyAsync();
} }

View File

@@ -64,6 +64,7 @@ extension DataServiceTraction on DataService {
_isTractionLoading = false; _isTractionLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline( Future<List<LocoAttrVersion>> fetchLocoTimeline(
@@ -466,6 +467,21 @@ extension DataServiceTraction on DataService {
} }
} }
Future<void> transferAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint('Failed to transfer 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

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeModeService extends ChangeNotifier {
static const _prefsKey = 'theme_mode_preference';
ThemeMode _mode = ThemeMode.system;
bool _loaded = false;
ThemeMode get mode => _mode;
bool get isLoaded => _loaded;
ThemeModeService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved != null) {
switch (saved) {
case 'light':
_mode = ThemeMode.light;
break;
case 'dark':
_mode = ThemeMode.dark;
break;
default:
_mode = ThemeMode.system;
}
}
_loaded = true;
notifyListeners();
}
Future<void> setMode(ThemeMode mode) async {
_mode = mode;
final prefs = await SharedPreferences.getInstance();
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
_ => 'system',
};
await prefs.setString(_prefsKey, value);
notifyListeners();
}
}

View File

@@ -21,14 +21,17 @@ 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';
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; 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/accent_color_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = final GlobalKey<NavigatorState> _shellNavigatorKey =
@@ -103,12 +106,6 @@ class _MyAppState extends State<MyApp> {
late final GoRouter _router; late final GoRouter _router;
bool _routerInitialized = false; bool _routerInitialized = false;
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -188,10 +185,27 @@ class _MyAppState extends State<MyApp> {
'', '',
) )
: null; : null;
final transferFromLocoIdStr =
state.uri.queryParameters['transferFromLocoId'];
final transferFromLocoId = transferFromLocoIdStr != null
? int.tryParse(transferFromLocoIdStr)
: state.extra is Map
? int.tryParse(
(state.extra as Map)['transferFromLocoId']
?.toString() ??
'',
)
: null;
final transferFromLabel = state.uri.queryParameters['transferFromLabel'] ??
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
final selectionMode = final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) || (selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null; replacementPendingLocoId != null ||
transferFromLocoId != null;
final selectionSingle = replacementPendingLocoId != null || final selectionSingle = replacementPendingLocoId != null ||
transferFromLocoId != null ||
selectionParam?.toLowerCase() == 'single' || selectionParam?.toLowerCase() == 'single' ||
selectionParam == '1' || selectionParam == '1' ||
selectionParam?.toLowerCase() == 'true'; selectionParam?.toLowerCase() == 'true';
@@ -199,6 +213,8 @@ class _MyAppState extends State<MyApp> {
selectionMode: selectionMode, selectionMode: selectionMode,
selectionSingle: selectionSingle, selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId, replacementPendingLocoId: replacementPendingLocoId,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId,
); );
}, },
), ),
@@ -206,6 +222,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(),
@@ -223,7 +244,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(
@@ -325,20 +354,34 @@ class _MyAppState extends State<MyApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
final seedColor =
accent.hasSavedSeed ? accent.seedColor : AccentColorService.defaultSeed;
final useSystemColors = accent.useSystem;
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final colorSchemeLight = useSystemColors && lightDynamic != null
? lightDynamic
: ColorScheme.fromSeed(seedColor: seedColor);
final colorSchemeDark = useSystemColors && darkDynamic != null
? darkDynamic
: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
return MaterialApp.router( return MaterialApp.router(
title: 'Mileograph', title: 'Mileograph',
routerConfig: _router, routerConfig: _router,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: lightDynamic ?? defaultLight, colorScheme: colorSchemeLight,
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark, colorScheme: colorSchemeDark,
), ),
themeMode: ThemeMode.system, themeMode: themeModeService.mode,
); );
}, },
); );
@@ -494,10 +537,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"),
@@ -513,6 +553,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.0+8 version: 0.7.3+11
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1