Compare commits
3 Commits
45042b5001
...
0.7.4-dev.
| Author | SHA1 | Date | |
|---|---|---|---|
| 559f79b805 | |||
| 3b7ec31e5d | |||
| e9b328e7e6 |
@@ -18,10 +18,12 @@ 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();
|
||||||
@@ -41,7 +43,13 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
await _restorePendingVisibility();
|
if (widget.forceShowPending) {
|
||||||
|
setState(() {
|
||||||
|
_showPending = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await _restorePendingVisibility();
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _load();
|
await _load();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -716,9 +716,14 @@ bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
|
|||||||
|
|
||||||
List<_ValueSegment> _segmentsForEntries(
|
List<_ValueSegment> _segmentsForEntries(
|
||||||
List<LocoAttrVersion> items,
|
List<LocoAttrVersion> items,
|
||||||
DateTime now,
|
DateTime now, {
|
||||||
) {
|
bool? clampToNextStart,
|
||||||
|
}) {
|
||||||
if (items.isEmpty) return const [];
|
if (items.isEmpty) return const [];
|
||||||
|
final hasPending = items.any((e) => e.isPending);
|
||||||
|
final hasApproved = items.any((e) => !e.isPending);
|
||||||
|
final shouldClamp =
|
||||||
|
clampToNextStart ?? (hasPending && hasApproved);
|
||||||
final sorted = [...items];
|
final sorted = [...items];
|
||||||
sorted.sort(
|
sorted.sort(
|
||||||
(a, b) => (_effectiveStart(a) ?? now)
|
(a, b) => (_effectiveStart(a) ?? now)
|
||||||
@@ -731,7 +736,13 @@ List<_ValueSegment> _segmentsForEntries(
|
|||||||
final nextStart = i < sorted.length - 1
|
final nextStart = i < sorted.length - 1
|
||||||
? _effectiveStart(sorted[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(
|
||||||
@@ -745,6 +756,53 @@ List<_ValueSegment> _segmentsForEntries(
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LocoAttrVersion> _applyPendingOverrides(
|
||||||
|
List<LocoAttrVersion> approved,
|
||||||
|
List<LocoAttrVersion> pending,
|
||||||
|
) {
|
||||||
|
if (pending.isEmpty) return approved;
|
||||||
|
final pendingByStart = <int, LocoAttrVersion>{};
|
||||||
|
final extraPending = <LocoAttrVersion>[];
|
||||||
|
for (final entry in pending) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
final key = _startKey(start);
|
||||||
|
pendingByStart[key] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
final applied = <LocoAttrVersion>[];
|
||||||
|
final seenKeys = <int>{};
|
||||||
|
for (final entry in approved) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
final key = _startKey(start);
|
||||||
|
if (pendingByStart.containsKey(key)) {
|
||||||
|
if (!seenKeys.contains(key)) {
|
||||||
|
applied.add(pendingByStart[key]!);
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applied.add(entry);
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final entry in pendingByStart.values) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
final key = _startKey(start);
|
||||||
|
if (!seenKeys.contains(key)) {
|
||||||
|
extraPending.add(entry);
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraPending.isNotEmpty) {
|
||||||
|
applied.addAll(extraPending);
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
List<DateTime> _buildBoundaries(
|
List<DateTime> _buildBoundaries(
|
||||||
List<_ValueSegment> segments,
|
List<_ValueSegment> segments,
|
||||||
DateTime now,
|
DateTime now,
|
||||||
@@ -904,14 +962,17 @@ class _TimelineModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final hasOverlap = overlapByUser.isNotEmpty;
|
final hasOverlap = overlapByUser.isNotEmpty;
|
||||||
final canToggle = pending.length > 1 && !hasOverlap;
|
final canToggle = pending.isNotEmpty && !hasOverlap;
|
||||||
final isExpanded = expandedAttrCodes.contains(attr);
|
final isExpanded = expandedAttrCodes.contains(attr);
|
||||||
|
final shouldShowPendingRows = isExpanded || hasOverlap;
|
||||||
|
|
||||||
final nonOverlapPending =
|
final nonOverlapPending =
|
||||||
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
|
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
|
||||||
final baseEntries = isExpanded ? approved : [...approved, ...nonOverlapPending];
|
final baseEntries =
|
||||||
final baseSegments =
|
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
|
||||||
isExpanded ? approvedSegments : _segmentsForEntries(baseEntries, now);
|
final baseSegments = shouldShowPendingRows
|
||||||
|
? approvedSegments
|
||||||
|
: _segmentsForEntries(baseEntries, now);
|
||||||
|
|
||||||
rowSpecs.add(
|
rowSpecs.add(
|
||||||
_TimelineRowSpec.primary(
|
_TimelineRowSpec.primary(
|
||||||
@@ -923,7 +984,6 @@ class _TimelineModel {
|
|||||||
);
|
);
|
||||||
allSegments.addAll(baseSegments);
|
allSegments.addAll(baseSegments);
|
||||||
|
|
||||||
final shouldShowPendingRows = isExpanded || hasOverlap;
|
|
||||||
if (shouldShowPendingRows) {
|
if (shouldShowPendingRows) {
|
||||||
final users = isExpanded
|
final users = isExpanded
|
||||||
? pendingByUser.keys.toList()
|
? pendingByUser.keys.toList()
|
||||||
@@ -934,11 +994,9 @@ class _TimelineModel {
|
|||||||
? (pendingByUser[user] ?? const [])
|
? (pendingByUser[user] ?? const [])
|
||||||
: (overlapByUser[user] ?? const []);
|
: (overlapByUser[user] ?? const []);
|
||||||
if (pendingEntries.isEmpty) continue;
|
if (pendingEntries.isEmpty) continue;
|
||||||
final userPendingSegments = _segmentsForEntries(pendingEntries, now);
|
final appliedEntries =
|
||||||
final combinedSegments = [
|
_applyPendingOverrides(approved, pendingEntries);
|
||||||
...approvedSegments,
|
final combinedSegments = _segmentsForEntries(appliedEntries, now);
|
||||||
...userPendingSegments,
|
|
||||||
];
|
|
||||||
rowSpecs.add(
|
rowSpecs.add(
|
||||||
_TimelineRowSpec.pending(
|
_TimelineRowSpec.pending(
|
||||||
attrCode: attr,
|
attrCode: attr,
|
||||||
|
|||||||
@@ -130,11 +130,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
int decimals = 1,
|
int decimals = 1,
|
||||||
bool includeUnit = true,
|
bool includeUnit = true,
|
||||||
}) {
|
}) {
|
||||||
return units.format(
|
return units.format(miles, decimals: decimals, includeUnit: includeUnit);
|
||||||
miles,
|
|
||||||
decimals: decimals,
|
|
||||||
includeUnit: includeUnit,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _manualMileageLabel(DistanceUnit unit) {
|
String _manualMileageLabel(DistanceUnit unit) {
|
||||||
@@ -191,15 +187,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double _milesFromInputWithUnit(DistanceUnit unit) {
|
double _milesFromInputWithUnit(DistanceUnit unit) {
|
||||||
return DistanceFormatter(unit)
|
return DistanceFormatter(
|
||||||
.parseInputMiles(_mileageController.text.trim()) ??
|
unit,
|
||||||
|
).parseInputMiles(_mileageController.text.trim()) ??
|
||||||
0;
|
0;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<UserSummary> _friendsFromFriendships(
|
List<UserSummary> _friendsFromFriendships(DataService data, String? selfId) {
|
||||||
DataService data,
|
|
||||||
String? selfId,
|
|
||||||
) {
|
|
||||||
final friends = <UserSummary>[];
|
final friends = <UserSummary>[];
|
||||||
for (final f in data.friendships) {
|
for (final f in data.friendships) {
|
||||||
final other = _friendFromFriendship(f, selfId);
|
final other = _friendFromFriendship(f, selfId);
|
||||||
@@ -300,9 +294,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final baseFriends = _friendsFromFriendships(data, auth.userId);
|
final baseFriends = _friendsFromFriendships(data, auth.userId);
|
||||||
final initialSelectedIds = {..._shareUserIds};
|
final initialSelectedIds = {..._shareUserIds};
|
||||||
final initialSelectedUsers = {
|
final initialSelectedUsers = {for (final u in _shareUsers) u.userId: u};
|
||||||
for (final u in _shareUsers) u.userId: u,
|
|
||||||
};
|
|
||||||
|
|
||||||
final result = await showModalBottomSheet<List<UserSummary>>(
|
final result = await showModalBottomSheet<List<UserSummary>>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -334,8 +326,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
searchError = null;
|
searchError = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final results =
|
final results = await data.searchUsers(
|
||||||
await data.searchUsers(trimmed, friendsOnly: true);
|
trimmed,
|
||||||
|
friendsOnly: true,
|
||||||
|
);
|
||||||
setModalState(() {
|
setModalState(() {
|
||||||
searchResults = results;
|
searchResults = results;
|
||||||
});
|
});
|
||||||
@@ -414,8 +408,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
itemCount: list.length,
|
itemCount: list.length,
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final user = list[index];
|
final user = list[index];
|
||||||
final isSelected =
|
final isSelected = selectedIds.contains(
|
||||||
selectedIds.contains(user.userId);
|
user.userId,
|
||||||
|
);
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
value: isSelected,
|
value: isSelected,
|
||||||
title: Text(user.displayName),
|
title: Text(user.displayName),
|
||||||
@@ -481,8 +476,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (previousUnit == currentUnit) return;
|
if (previousUnit == currentUnit) return;
|
||||||
|
|
||||||
final miles = _milesFromInputWithUnit(previousUnit);
|
final miles = _milesFromInputWithUnit(previousUnit);
|
||||||
final nextText = DistanceFormatter(currentUnit)
|
final nextText = DistanceFormatter(
|
||||||
.format(miles, decimals: 2, includeUnit: false);
|
currentUnit,
|
||||||
|
).format(miles, decimals: 2, includeUnit: false);
|
||||||
_mileageController.text = nextText;
|
_mileageController.text = nextText;
|
||||||
_lastDistanceUnit = currentUnit;
|
_lastDistanceUnit = currentUnit;
|
||||||
}
|
}
|
||||||
@@ -842,8 +838,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final destination = json['leg_destination'] as String? ?? '';
|
final destination = json['leg_destination'] as String? ?? '';
|
||||||
final hasEndTime = endTime != null || endDelay != 0;
|
final hasEndTime = endTime != null || endDelay != 0;
|
||||||
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
|
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
|
||||||
final destinationTime =
|
final destinationTime = DateTime.tryParse(
|
||||||
DateTime.tryParse(json['leg_destination_time'] ?? '');
|
json['leg_destination_time'] ?? '',
|
||||||
|
);
|
||||||
final hasOriginTime = originTime != null;
|
final hasOriginTime = originTime != null;
|
||||||
final hasDestinationTime = destinationTime != null;
|
final hasDestinationTime = destinationTime != null;
|
||||||
|
|
||||||
@@ -860,8 +857,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedOriginDate = originTime ?? beginTime;
|
_selectedOriginDate = originTime ?? beginTime;
|
||||||
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
||||||
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
||||||
_selectedDestinationTime =
|
_selectedDestinationTime = TimeOfDay.fromDateTime(
|
||||||
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
|
destinationTime ?? endTime ?? beginTime,
|
||||||
|
);
|
||||||
_hasOriginTime = hasOriginTime;
|
_hasOriginTime = hasOriginTime;
|
||||||
_hasDestinationTime = hasDestinationTime;
|
_hasDestinationTime = hasDestinationTime;
|
||||||
_useManualMileage = useManual;
|
_useManualMileage = useManual;
|
||||||
@@ -927,18 +925,20 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
);
|
);
|
||||||
final tractionItems = _buildTractionFromApi(
|
final tractionItems = _buildTractionFromApi(
|
||||||
entry.locos
|
entry.locos
|
||||||
.map((l) => {
|
.map(
|
||||||
"loco_id": l.id,
|
(l) => {
|
||||||
"type": l.type,
|
"loco_id": l.id,
|
||||||
"number": l.number,
|
"type": l.type,
|
||||||
"class": l.locoClass,
|
"number": l.number,
|
||||||
"name": l.name,
|
"class": l.locoClass,
|
||||||
"operator": l.operator,
|
"name": l.name,
|
||||||
"notes": l.notes,
|
"operator": l.operator,
|
||||||
"evn": l.evn,
|
"notes": l.notes,
|
||||||
"alloc_pos": l.allocPos,
|
"evn": l.evn,
|
||||||
"alloc_powering": l.powering ? 1 : 0,
|
"alloc_pos": l.allocPos,
|
||||||
})
|
"alloc_powering": l.powering ? 1 : 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
final beginDelay = entry.beginDelayMinutes ?? 0;
|
final beginDelay = entry.beginDelayMinutes ?? 0;
|
||||||
@@ -963,8 +963,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedOriginDate = originTime ?? beginTime;
|
_selectedOriginDate = originTime ?? beginTime;
|
||||||
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
||||||
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
||||||
_selectedDestinationTime =
|
_selectedDestinationTime = TimeOfDay.fromDateTime(
|
||||||
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
|
destinationTime ?? endTime ?? beginTime,
|
||||||
|
);
|
||||||
_hasOriginTime = hasOriginTime;
|
_hasOriginTime = hasOriginTime;
|
||||||
_hasDestinationTime = hasDestinationTime;
|
_hasDestinationTime = hasDestinationTime;
|
||||||
_useManualMileage = useManual;
|
_useManualMileage = useManual;
|
||||||
@@ -980,12 +981,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_endDelayController.text = endDelay.toString();
|
_endDelayController.text = endDelay.toString();
|
||||||
_mileageController.text = mileageVal == 0
|
_mileageController.text = mileageVal == 0
|
||||||
? ''
|
? ''
|
||||||
: _formatDistance(
|
: _formatDistance(units, mileageVal, decimals: 2, includeUnit: false);
|
||||||
units,
|
|
||||||
mileageVal,
|
|
||||||
decimals: 2,
|
|
||||||
includeUnit: false,
|
|
||||||
);
|
|
||||||
_tractionItems
|
_tractionItems
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(tractionItems);
|
..addAll(tractionItems);
|
||||||
@@ -1187,10 +1183,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (!matchValue) ...[
|
if (!matchValue) ...[
|
||||||
_stationField(
|
_stationField(label: label, controller: controller),
|
||||||
label: label,
|
|
||||||
controller: controller,
|
|
||||||
),
|
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
value: hasTime,
|
value: hasTime,
|
||||||
onChanged: onTimeChanged,
|
onChanged: onTimeChanged,
|
||||||
@@ -1237,8 +1230,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed: _isEditing || _activeLegShare != null
|
||||||
_isEditing || _activeLegShare != null ? null : _openDrafts,
|
? null
|
||||||
|
: _openDrafts,
|
||||||
icon: const Icon(Icons.list_alt, size: 16),
|
icon: const Icon(Icons.list_alt, size: 16),
|
||||||
label: const Text('Drafts'),
|
label: const Text('Drafts'),
|
||||||
),
|
),
|
||||||
@@ -1246,26 +1240,27 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
onPressed: _isEditing ||
|
onPressed:
|
||||||
_savingDraft ||
|
_isEditing ||
|
||||||
_submitting ||
|
_savingDraft ||
|
||||||
_activeLegShare != null
|
_submitting ||
|
||||||
? null
|
_activeLegShare != null
|
||||||
: _saveDraftManually,
|
? null
|
||||||
icon: _savingDraft
|
: _saveDraftManually,
|
||||||
? const SizedBox(
|
icon: _savingDraft
|
||||||
width: 16,
|
? const SizedBox(
|
||||||
height: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.save_alt, size: 16),
|
: const Icon(Icons.save_alt, size: 16),
|
||||||
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
|
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
@@ -1276,17 +1271,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
: () => _resetFormState(clearDraft: true),
|
: () => _resetFormState(clearDraft: true),
|
||||||
icon: const Icon(Icons.clear, size: 16),
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
label: const Text('Clear form'),
|
label: const Text('Clear form'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
if (_activeLegShare != null) ...[
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
if (_activeLegShare != null) ...[
|
_buildSharedBanner(),
|
||||||
const SizedBox(height: 8),
|
],
|
||||||
_buildSharedBanner(),
|
|
||||||
],
|
|
||||||
_buildTripSelector(context),
|
_buildTripSelector(context),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_activeLegShare == null) _buildShareSection(context),
|
if (_activeLegShare == null) _buildShareSection(context),
|
||||||
_dateTimeGroup(
|
_dateTimeGroup(
|
||||||
context,
|
context,
|
||||||
title: 'Departure time',
|
title: 'Departure time',
|
||||||
onDateTap: _pickDate,
|
onDateTap: _pickDate,
|
||||||
@@ -1393,8 +1388,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
onTimeChanged: _submitting ? null : _toggleDestinationTime,
|
onTimeChanged: _submitting ? null : _toggleDestinationTime,
|
||||||
matchLabel: 'Match entry end',
|
matchLabel: 'Match entry end',
|
||||||
matchValue: _matchDestinationToEntry,
|
matchValue: _matchDestinationToEntry,
|
||||||
onMatchChanged:
|
onMatchChanged: _submitting ? null : _toggleMatchDestination,
|
||||||
_submitting ? null : _toggleMatchDestination,
|
|
||||||
pickerBuilder: () => _dateTimeGroupSimple(
|
pickerBuilder: () => _dateTimeGroupSimple(
|
||||||
context,
|
context,
|
||||||
title: 'Destination arrival',
|
title: 'Destination arrival',
|
||||||
@@ -1428,7 +1422,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final tractionPanel = _section('Traction', [
|
final tractionPanel = _section('Traction', [
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: ElevatedButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _openTractionPicker,
|
onPressed: _openTractionPicker,
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
label: const Text('Search traction'),
|
label: const Text('Search traction'),
|
||||||
@@ -1440,13 +1434,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true
|
final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true
|
||||||
? _routeResult!.calculatedRoute.first
|
? _routeResult!.calculatedRoute.first
|
||||||
: (_routeResult?.inputRoute.isNotEmpty == true
|
: (_routeResult?.inputRoute.isNotEmpty == true
|
||||||
? _routeResult!.inputRoute.first
|
? _routeResult!.inputRoute.first
|
||||||
: _startController.text.trim());
|
: _startController.text.trim());
|
||||||
final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true
|
final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true
|
||||||
? _routeResult!.calculatedRoute.last
|
? _routeResult!.calculatedRoute.last
|
||||||
: (_routeResult?.inputRoute.isNotEmpty == true
|
: (_routeResult?.inputRoute.isNotEmpty == true
|
||||||
? _routeResult!.inputRoute.last
|
? _routeResult!.inputRoute.last
|
||||||
: _endController.text.trim());
|
: _endController.text.trim());
|
||||||
|
|
||||||
final mileagePanel = _section(
|
final mileagePanel = _section(
|
||||||
'Your Journey',
|
'Your Journey',
|
||||||
@@ -1456,12 +1450,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _openCalculator,
|
onPressed: _openCalculator,
|
||||||
icon: const Icon(Icons.calculate, size: 18),
|
icon: const Icon(Icons.calculate, size: 18),
|
||||||
label: const Text('Open mileage calculator'),
|
label: const Text('Open mileage calculator'),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _reverseRouteAndEndpoints,
|
onPressed: _reverseRouteAndEndpoints,
|
||||||
icon: const Icon(Icons.swap_horiz),
|
icon: const Icon(Icons.swap_horiz),
|
||||||
label: const Text('Reverse route'),
|
label: const Text('Reverse route'),
|
||||||
@@ -1493,8 +1487,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: mileageLabel,
|
labelText: mileageLabel,
|
||||||
helperText: currentDistanceUnit ==
|
helperText:
|
||||||
DistanceUnit.milesChains
|
currentDistanceUnit == DistanceUnit.milesChains
|
||||||
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
|
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
|
||||||
: null,
|
: null,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
@@ -1571,7 +1565,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ElevatedButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _submitting ? null : _submit,
|
onPressed: _submitting ? null : _submit,
|
||||||
icon: _submitting
|
icon: _submitting
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
@@ -1783,52 +1777,52 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
},
|
},
|
||||||
fieldViewBuilder:
|
fieldViewBuilder:
|
||||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
if (textEditingController.text != controller.text) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (textEditingController.text != controller.text) {
|
if (textEditingController.text != controller.text) {
|
||||||
textEditingController.value = controller.value;
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (textEditingController.text != controller.text) {
|
||||||
|
textEditingController.value = controller.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
return TextFormField(
|
||||||
}
|
controller: textEditingController,
|
||||||
return TextFormField(
|
focusNode: focusNode,
|
||||||
controller: textEditingController,
|
textCapitalization: TextCapitalization.words,
|
||||||
focusNode: focusNode,
|
decoration: InputDecoration(
|
||||||
textCapitalization: TextCapitalization.words,
|
labelText: label,
|
||||||
decoration: InputDecoration(
|
border: const OutlineInputBorder(),
|
||||||
labelText: label,
|
suffixIcon: _loadingStations
|
||||||
border: const OutlineInputBorder(),
|
? const SizedBox(
|
||||||
suffixIcon: _loadingStations
|
width: 20,
|
||||||
? const SizedBox(
|
height: 20,
|
||||||
width: 20,
|
child: Padding(
|
||||||
height: 20,
|
padding: EdgeInsets.all(4.0),
|
||||||
child: Padding(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
padding: EdgeInsets.all(4.0),
|
),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
)
|
||||||
),
|
: const Icon(Icons.search),
|
||||||
)
|
),
|
||||||
: const Icon(Icons.search),
|
textInputAction: TextInputAction.done,
|
||||||
),
|
onChanged: (_) {
|
||||||
textInputAction: TextInputAction.done,
|
controller.value = textEditingController.value;
|
||||||
onChanged: (_) {
|
_saveDraft();
|
||||||
controller.value = textEditingController.value;
|
},
|
||||||
_saveDraft();
|
onFieldSubmitted: (_) {
|
||||||
|
final matches = _matchStations(
|
||||||
|
textEditingController.text,
|
||||||
|
stationNames,
|
||||||
|
).toList();
|
||||||
|
if (matches.isNotEmpty) {
|
||||||
|
final top = matches.first;
|
||||||
|
controller.text = top;
|
||||||
|
textEditingController.text = top;
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
focusNode.unfocus();
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onFieldSubmitted: (_) {
|
|
||||||
final matches = _matchStations(
|
|
||||||
textEditingController.text,
|
|
||||||
stationNames,
|
|
||||||
).toList();
|
|
||||||
if (matches.isNotEmpty) {
|
|
||||||
final top = matches.first;
|
|
||||||
controller.text = top;
|
|
||||||
textEditingController.text = top;
|
|
||||||
_saveDraft();
|
|
||||||
}
|
|
||||||
focusNode.unfocus();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1842,7 +1836,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _insertCandidate(List<String> best, String candidate, {required int max}) {
|
void _insertCandidate(
|
||||||
|
List<String> best,
|
||||||
|
String candidate, {
|
||||||
|
required int max,
|
||||||
|
}) {
|
||||||
final existingIndex = best.indexOf(candidate);
|
final existingIndex = best.indexOf(candidate);
|
||||||
if (existingIndex >= 0) return;
|
if (existingIndex >= 0) return;
|
||||||
|
|
||||||
@@ -2016,7 +2014,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpperCaseTextFormatter extends TextInputFormatter {
|
class _UpperCaseTextFormatter extends TextInputFormatter {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ enum _TractionMoreAction {
|
|||||||
classStats,
|
classStats,
|
||||||
classLeaderboard,
|
classLeaderboard,
|
||||||
adminPending,
|
adminPending,
|
||||||
|
adminPendingChanges,
|
||||||
}
|
}
|
||||||
|
|
||||||
class TractionPage extends StatefulWidget {
|
class TractionPage extends StatefulWidget {
|
||||||
@@ -14,6 +15,7 @@ class TractionPage extends StatefulWidget {
|
|||||||
this.replacementPendingLocoId,
|
this.replacementPendingLocoId,
|
||||||
this.transferFromLabel,
|
this.transferFromLabel,
|
||||||
this.transferFromLocoId,
|
this.transferFromLocoId,
|
||||||
|
this.transferAllAllocations = false,
|
||||||
this.onSelect,
|
this.onSelect,
|
||||||
this.selectedKeys = const {},
|
this.selectedKeys = const {},
|
||||||
});
|
});
|
||||||
@@ -23,6 +25,7 @@ class TractionPage extends StatefulWidget {
|
|||||||
final int? replacementPendingLocoId;
|
final int? replacementPendingLocoId;
|
||||||
final String? transferFromLabel;
|
final String? transferFromLabel;
|
||||||
final int? transferFromLocoId;
|
final int? transferFromLocoId;
|
||||||
|
final bool transferAllAllocations;
|
||||||
final ValueChanged<LocoSummary>? onSelect;
|
final ValueChanged<LocoSummary>? onSelect;
|
||||||
final Set<String> selectedKeys;
|
final Set<String> selectedKeys;
|
||||||
|
|
||||||
@@ -38,6 +41,12 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
bool _mileageFirst = true;
|
bool _mileageFirst = true;
|
||||||
bool _initialised = false;
|
bool _initialised = false;
|
||||||
int? get _transferFromLocoId => widget.transferFromLocoId;
|
int? get _transferFromLocoId => widget.transferFromLocoId;
|
||||||
|
bool get _transferAllAllocations {
|
||||||
|
if (widget.transferAllAllocations) return true;
|
||||||
|
final param =
|
||||||
|
GoRouterState.of(context).uri.queryParameters['transferAll'];
|
||||||
|
return param?.toLowerCase() == 'true' || param == '1';
|
||||||
|
}
|
||||||
bool _showAdvancedFilters = false;
|
bool _showAdvancedFilters = false;
|
||||||
String? _selectedClass;
|
String? _selectedClass;
|
||||||
late Set<String> _selectedKeys;
|
late Set<String> _selectedKeys;
|
||||||
@@ -708,6 +717,19 @@ 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) {
|
||||||
@@ -765,6 +787,12 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
items.add(
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _TractionMoreAction.adminPendingChanges,
|
||||||
|
child: Text('Pending changes'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
@@ -1295,13 +1323,19 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
context: navContext,
|
context: navContext,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Transfer allocations?'),
|
title: Text(
|
||||||
|
_transferAllAllocations
|
||||||
|
? 'Transfer all allocations?'
|
||||||
|
: 'Transfer allocations?',
|
||||||
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Transfer all allocations from $fromLabel to $toLabel?',
|
_transferAllAllocations
|
||||||
|
? 'Transfer all user allocations from $fromLabel to $toLabel?'
|
||||||
|
: 'Transfer all allocations from $fromLabel to $toLabel?',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -1331,10 +1365,23 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
if (!navContext.mounted) return;
|
if (!navContext.mounted) return;
|
||||||
try {
|
try {
|
||||||
final data = navContext.read<DataService>();
|
final data = navContext.read<DataService>();
|
||||||
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
|
if (_transferAllAllocations) {
|
||||||
|
await data.transferAllAllocations(
|
||||||
|
fromLocoId: fromId,
|
||||||
|
toLocoId: target.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
|
||||||
|
}
|
||||||
if (navContext.mounted) {
|
if (navContext.mounted) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(content: Text('Allocations transferred')),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_transferAllAllocations
|
||||||
|
? 'All allocations transferred'
|
||||||
|
: 'Allocations transferred',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await _refreshTraction(preservePosition: true);
|
await _refreshTraction(preservePosition: true);
|
||||||
@@ -1406,7 +1453,9 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Transferring allocations from $label. Select a loco to transfer to.',
|
_transferAllAllocations
|
||||||
|
? 'Transferring all allocations from $label. Select a loco to transfer to.'
|
||||||
|
: 'Transferring allocations from $label. Select a loco to transfer to.',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
152
lib/components/pages/traction/traction_pending_changes_page.dart
Normal file
152
lib/components/pages/traction/traction_pending_changes_page.dart
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/components/traction/traction_card.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/api_service.dart';
|
||||||
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class TractionPendingChangesPage extends StatefulWidget {
|
||||||
|
const TractionPendingChangesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TractionPendingChangesPage> createState() =>
|
||||||
|
_TractionPendingChangesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<LocoSummary> _locos = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiService>();
|
||||||
|
final json = await api.get('/event/pending/locos');
|
||||||
|
if (json is List) {
|
||||||
|
setState(() {
|
||||||
|
_locos = json
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => LocoSummary.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Unexpected response';
|
||||||
|
_locos = const [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_locos = const [];
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: const Text('Pending changes'),
|
||||||
|
),
|
||||||
|
body: isElevated
|
||||||
|
? RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: _buildBody(context),
|
||||||
|
)
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: const [
|
||||||
|
Text('Admin access required.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
if (_isLoading && _locos.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Failed to load pending changes: $_error',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_locos.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: const [
|
||||||
|
Text('No pending changes found.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _locos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final loco = _locos[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: TractionCard(
|
||||||
|
loco: loco,
|
||||||
|
selectionMode: false,
|
||||||
|
isSelected: false,
|
||||||
|
onShowInfo: () => showTractionDetails(
|
||||||
|
context,
|
||||||
|
loco,
|
||||||
|
onActionComplete: _load,
|
||||||
|
),
|
||||||
|
onOpenTimeline: () => context.push(
|
||||||
|
'/traction/${loco.id}/timeline',
|
||||||
|
extra: {
|
||||||
|
'label': '${loco.locoClass} ${loco.number}'.trim(),
|
||||||
|
'showPending': true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
|
||||||
|
onActionComplete: _load,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -724,10 +724,12 @@ Future<void> showTractionDetails(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
if (hasMileageOrTrips)
|
||||||
onPressed: () {
|
FilledButton.icon(
|
||||||
Navigator.of(ctx).pop();
|
onPressed: () {
|
||||||
final transferLabel = '${loco.locoClass} ${loco.number}'.trim();
|
Navigator.of(ctx).pop();
|
||||||
|
final transferLabel =
|
||||||
|
'${loco.locoClass} ${loco.number}'.trim();
|
||||||
navContext.push(
|
navContext.push(
|
||||||
Uri(
|
Uri(
|
||||||
path: '/traction',
|
path: '/traction',
|
||||||
@@ -735,18 +737,20 @@ Future<void> showTractionDetails(
|
|||||||
'selection': 'single',
|
'selection': 'single',
|
||||||
'transferFromLocoId': loco.id.toString(),
|
'transferFromLocoId': loco.id.toString(),
|
||||||
'transferFromLabel': transferLabel,
|
'transferFromLabel': transferLabel,
|
||||||
|
'transferAll': '0',
|
||||||
},
|
},
|
||||||
).toString(),
|
).toString(),
|
||||||
extra: {
|
extra: {
|
||||||
'selection': 'single',
|
'selection': 'single',
|
||||||
'transferFromLocoId': loco.id,
|
'transferFromLocoId': loco.id,
|
||||||
'transferFromLabel': transferLabel,
|
'transferFromLabel': transferLabel,
|
||||||
|
'transferAll': false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.swap_horiz),
|
icon: const Icon(Icons.swap_horiz),
|
||||||
label: const Text('Transfer allocations'),
|
label: const Text('Transfer allocations'),
|
||||||
),
|
),
|
||||||
if (auth.isElevated || canDeleteAsOwner) ...[
|
if (auth.isElevated || canDeleteAsOwner) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ExpansionTile(
|
ExpansionTile(
|
||||||
@@ -757,6 +761,34 @@ Future<void> showTractionDetails(
|
|||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
|
if (auth.isElevated) ...[
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final transferLabel =
|
||||||
|
'${loco.locoClass} ${loco.number}'.trim();
|
||||||
|
navContext.push(
|
||||||
|
Uri(
|
||||||
|
path: '/traction',
|
||||||
|
queryParameters: {
|
||||||
|
'selection': 'single',
|
||||||
|
'transferFromLocoId': loco.id.toString(),
|
||||||
|
'transferFromLabel': transferLabel,
|
||||||
|
'transferAll': 'true',
|
||||||
|
},
|
||||||
|
).toString(),
|
||||||
|
extra: {
|
||||||
|
'selection': 'single',
|
||||||
|
'transferFromLocoId': loco.id,
|
||||||
|
'transferFromLabel': transferLabel,
|
||||||
|
'transferAll': true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Transfer all allocations'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|||||||
@@ -482,6 +482,23 @@ extension DataServiceTraction on DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> transferAllAllocations({
|
||||||
|
required int fromLocoId,
|
||||||
|
required int toLocoId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await api.post('/loco/alloc/transfer?transferAll=true', {
|
||||||
|
'from_loco_id': fromLocoId,
|
||||||
|
'to_loco_id': toLocoId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to transfer all allocations $fromLocoId -> $toLocoId: $e',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> adminDeleteLoco({required int locoId}) async {
|
Future<void> adminDeleteLoco({required int locoId}) async {
|
||||||
try {
|
try {
|
||||||
await api.delete('/loco/admin/delete/$locoId');
|
await api.delete('/loco/admin/delete/$locoId');
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import 'package:mileograph_flutter/components/pages/settings.dart';
|
|||||||
import 'package:mileograph_flutter/components/pages/stats.dart';
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
|
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_page.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
|
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
|
||||||
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
||||||
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
|
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
|
||||||
@@ -199,6 +200,22 @@ class _MyAppState extends State<MyApp> {
|
|||||||
(state.extra is Map
|
(state.extra is Map
|
||||||
? (state.extra as Map)['transferFromLabel']?.toString()
|
? (state.extra as Map)['transferFromLabel']?.toString()
|
||||||
: null);
|
: null);
|
||||||
|
bool transferAllAllocations = false;
|
||||||
|
final transferAllParam =
|
||||||
|
state.uri.queryParameters['transferAll'] ??
|
||||||
|
(state.extra is Map
|
||||||
|
? (state.extra as Map)['transferAll']?.toString()
|
||||||
|
: null);
|
||||||
|
if (transferAllParam != null) {
|
||||||
|
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
|
||||||
|
transferAllParam == '1';
|
||||||
|
}
|
||||||
|
if (!transferAllAllocations && state.extra is Map) {
|
||||||
|
final raw = (state.extra as Map)['transferAll'];
|
||||||
|
if (raw is bool) {
|
||||||
|
transferAllAllocations = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
final selectionMode =
|
final selectionMode =
|
||||||
(selectionParam != null && selectionParam.isNotEmpty) ||
|
(selectionParam != null && selectionParam.isNotEmpty) ||
|
||||||
replacementPendingLocoId != null ||
|
replacementPendingLocoId != null ||
|
||||||
@@ -214,6 +231,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
replacementPendingLocoId: replacementPendingLocoId,
|
replacementPendingLocoId: replacementPendingLocoId,
|
||||||
transferFromLabel: transferFromLabel,
|
transferFromLabel: transferFromLabel,
|
||||||
transferFromLocoId: transferFromLocoId,
|
transferFromLocoId: transferFromLocoId,
|
||||||
|
transferAllAllocations: transferAllAllocations,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -221,6 +239,11 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/traction/pending',
|
path: '/traction/pending',
|
||||||
builder: (context, state) => const TractionPendingPage(),
|
builder: (context, state) => const TractionPendingPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/traction/changes',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const TractionPendingChangesPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => const ProfilePage(),
|
||||||
@@ -238,7 +261,15 @@ class _MyAppState extends State<MyApp> {
|
|||||||
label = extra;
|
label = extra;
|
||||||
}
|
}
|
||||||
if (label.trim().isEmpty) label = 'Loco $locoId';
|
if (label.trim().isEmpty) label = 'Loco $locoId';
|
||||||
return LocoTimelinePage(locoId: locoId, locoLabel: label);
|
bool showPending = false;
|
||||||
|
if (extra is Map && extra['showPending'] is bool) {
|
||||||
|
showPending = extra['showPending'] as bool;
|
||||||
|
}
|
||||||
|
return LocoTimelinePage(
|
||||||
|
locoId: locoId,
|
||||||
|
locoLabel: label,
|
||||||
|
forceShowPending: showPending,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -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.3+11
|
version: 0.7.4+12
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user