add ability for non admins to add new traction, pending approval. Various QoL updates
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Successful in 1m14s
Release / android-build (push) Successful in 5m33s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s

This commit is contained in:
2026-01-05 22:11:02 +00:00
parent a755644c31
commit d5083e1cc7
18 changed files with 1585 additions and 173 deletions

View File

@@ -66,14 +66,68 @@ extension DataServiceTraction on DataService {
}
}
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
Future<List<LocoAttrVersion>> fetchLocoTimeline(
int locoId, {
bool includeAllPending = false,
}) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
final baseJson = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(baseJson);
final baseKeys = timeline
.map(_entryKey)
.where((key) => key.isNotEmpty)
.toSet();
final pendingEntries = <LocoAttrVersion>[];
final pendingSeen = <String>{};
void addPending(List<LocoAttrVersion> entries) {
for (final entry in entries) {
final key = _entryKey(entry);
if (pendingSeen.contains(key)) continue;
if (baseKeys.contains(key)) continue;
pendingSeen.add(key);
pendingEntries.add(entry);
baseKeys.add(key);
}
}
try {
final pendingJson =
await api.get('/event/pending/user?loco_id=$locoId');
addPending(
_parsePendingLocoEvents(
pendingJson,
locoId,
canModerate: false,
),
);
} catch (e) {
debugPrint('Failed to fetch pending loco events for $locoId: $e');
}
if (includeAllPending) {
try {
final pendingJson = await api.get('/event/pending?loco_id=$locoId');
addPending(
_parsePendingLocoEvents(
pendingJson,
locoId,
canModerate: true,
),
);
} catch (e) {
debugPrint('Failed to fetch all pending loco events for $locoId: $e');
}
}
final merged = [
...timeline,
...pendingEntries,
]..sort(LocoAttrVersion.compareByStart);
_locoTimelines[locoId] = merged;
return merged;
} catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
@@ -84,6 +138,173 @@ extension DataServiceTraction on DataService {
}
}
Future<List<LocoAttrVersion>> fetchUserPendingEvents(int locoId) async {
try {
final pendingJson = await api.get('/event/pending/user?loco_id=$locoId');
return _parsePendingLocoEvents(
pendingJson,
locoId,
canModerate: false,
);
} catch (e) {
debugPrint('Failed to fetch user pending events for $locoId: $e');
return const [];
}
}
List<LocoAttrVersion> _parsePendingLocoEvents(
dynamic json,
int fallbackLocoId, {
bool canModerate = false,
}) {
if (json is! List) return const <LocoAttrVersion>[];
final entries = <LocoAttrVersion>[];
final seen = <String>{};
for (final item in json) {
if (item is! Map) continue;
final map = _extractPendingEventMap(item);
final locoId = (map['loco_id'] as num?)?.toInt() ?? fallbackLocoId;
final maskedValidFrom = map['masked_valid_from']?.toString();
final precision = map['precision_level']?.toString();
final username = map['username']?.toString();
final sourceEventId = (map['loco_event_id'] as num?)?.toInt();
final startDate = _parsePendingDate(map);
final status = map['moderation_status']?.toString().toLowerCase();
if (status != null && status != 'pending') continue;
if (startDate == null &&
(maskedValidFrom == null || maskedValidFrom.trim().isEmpty)) {
continue;
}
final valueMap = _decodeEventValues(map['loco_event_value']);
if (valueMap.isEmpty) continue;
valueMap.forEach((attr, rawValue) {
final attrCode = attr.toString();
if (attrCode.isEmpty) return;
final key = [
sourceEventId?.toString() ?? '',
attrCode.toLowerCase(),
maskedValidFrom ?? '',
startDate?.toIso8601String() ?? '',
].join('|');
if (seen.contains(key)) return;
seen.add(key);
final parsedValue = _PendingTimelineValue.fromDynamic(rawValue);
entries.add(
LocoAttrVersion(
attrCode: attrCode,
locoId: locoId,
valueStr: parsedValue.valueStr,
valueInt: parsedValue.valueInt,
valueBool: parsedValue.valueBool,
valueDate: parsedValue.valueDate,
valueNorm: parsedValue.valueNorm ?? rawValue,
validFrom: startDate,
maskedValidFrom: maskedValidFrom,
precisionLevel: precision,
suggestedBy: username,
sourceEventId: sourceEventId,
isPending: true,
canModeratePending: canModerate,
),
);
});
}
return entries;
}
String _entryKey(LocoAttrVersion entry) {
final attr = entry.attrCode.toLowerCase();
final masked = entry.maskedValidFrom?.trim() ?? '';
final start = entry.validFrom?.toIso8601String() ?? '';
final source = entry.sourceEventId?.toString() ?? '';
return '$attr|$masked|$start|$source';
}
Map<String, dynamic> _extractPendingEventMap(Map raw) {
if (raw['event_info'] is Map) {
final eventInfo = Map<String, dynamic>.from(raw['event_info']);
final merged = {...eventInfo};
for (final key in [
'loco_id',
'masked_valid_from',
'precision_level',
'username',
'loco_event_id',
'earliest_date',
'valid_from',
'loco_event_date',
'event_year',
'event_month',
'event_day',
'loco_event_value',
]) {
merged.putIfAbsent(key, () => raw[key]);
}
return merged;
}
return Map<String, dynamic>.from(raw);
}
Map<String, dynamic> _decodeEventValues(dynamic raw) {
if (raw is Map) {
return raw.map((key, value) => MapEntry(key.toString(), value));
}
if (raw is String) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return const {};
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map) {
return decoded.map((key, value) => MapEntry(key.toString(), value));
}
} catch (_) {}
}
return const <String, dynamic>{};
}
DateTime? _parsePendingDate(Map<String, dynamic> json) {
DateTime? parseDate(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
// Try masked date by normalising unknown parts to 01 so we can position on the axis.
final masked = json['masked_valid_from']?.toString();
if (masked is String && masked.contains('-')) {
final sanitized = masked
.replaceAll(RegExp(r'[Xx?]{2}'), '01')
.replaceAll(RegExp(r'[Xx?]'), '1');
final parsedMasked = DateTime.tryParse(sanitized);
if (parsedMasked != null) return parsedMasked;
}
for (final key in ['earliest_date', 'valid_from', 'loco_event_date']) {
final parsed = parseDate(json[key]);
if (parsed != null) return parsed;
}
final year = _asNullableInt(json['event_year']);
if (year != null && year > 0) {
final monthValue = _asNullableInt(json['event_month']) ?? 1;
final dayValue = _asNullableInt(json['event_day']) ?? 1;
final month = monthValue.clamp(1, 12).toInt();
final day = dayValue.clamp(1, 31).toInt();
try {
return DateTime(year, month, day);
} catch (_) {}
}
return null;
}
int? _asNullableInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try {
final response = await api.put('/loco/new', payload);
@@ -101,8 +322,9 @@ extension DataServiceTraction on DataService {
}
}
Future<List<String>> fetchClassList() async {
if (_locoClasses.isNotEmpty) return _locoClasses;
Future<List<String>> fetchClassList({bool force = false}) async {
if (!force && _locoClasses.isNotEmpty) return _locoClasses;
if (force) _locoClasses = [];
try {
final json = await api.get('/loco/classlist');
if (json is List) {
@@ -214,4 +436,90 @@ extension DataServiceTraction on DataService {
return const [];
}
}
Future<void> acceptPendingLoco({required int locoId}) async {
try {
await api.put('/loco/pending/approve/$locoId', null);
} catch (e) {
debugPrint('Failed to approve pending loco $locoId: $e');
rethrow;
}
}
Future<void> rejectPendingLoco({
required int locoId,
int? replacementLocoId,
String? rejectedReason,
}) async {
try {
final body = <String, dynamic>{};
if (replacementLocoId != null) {
body['replacement_loco_id'] = replacementLocoId;
}
if (rejectedReason != null && rejectedReason.trim().isNotEmpty) {
body['rejected_reason'] = rejectedReason.trim();
}
await api.put('/loco/pending/reject/$locoId', body.isEmpty ? null : body);
} catch (e) {
debugPrint('Failed to reject pending loco $locoId: $e');
rethrow;
}
}
Future<void> adminDeleteLoco({required int locoId}) async {
try {
await api.delete('/loco/admin/delete/$locoId');
} catch (e) {
debugPrint('Failed to delete loco $locoId as admin: $e');
rethrow;
}
}
}
class _PendingTimelineValue {
final String? valueStr;
final int? valueInt;
final bool? valueBool;
final DateTime? valueDate;
final dynamic valueNorm;
const _PendingTimelineValue({
this.valueStr,
this.valueInt,
this.valueBool,
this.valueDate,
this.valueNorm,
});
factory _PendingTimelineValue.fromDynamic(dynamic raw) {
if (raw is Map || raw is List) {
return _PendingTimelineValue(valueStr: jsonEncode(raw));
}
if (raw is bool) return _PendingTimelineValue(valueBool: raw);
if (raw is int) return _PendingTimelineValue(valueInt: raw);
if (raw is num) return _PendingTimelineValue(valueNorm: raw);
if (raw is String) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return const _PendingTimelineValue(valueStr: '');
final lower = trimmed.toLowerCase();
if (['true', 'yes', 'y'].contains(lower)) {
return const _PendingTimelineValue(valueBool: true);
}
if (['false', 'no', 'n'].contains(lower)) {
return const _PendingTimelineValue(valueBool: false);
}
final intVal = int.tryParse(trimmed);
if (intVal != null) return _PendingTimelineValue(valueInt: intVal);
final doubleVal = double.tryParse(trimmed);
if (doubleVal != null) {
return _PendingTimelineValue(valueNorm: doubleVal);
}
final dateVal = DateTime.tryParse(trimmed);
if (dateVal != null) {
return _PendingTimelineValue(valueDate: dateVal);
}
return _PendingTimelineValue(valueStr: trimmed);
}
return _PendingTimelineValue(valueNorm: raw);
}
}