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
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:
@@ -588,6 +588,39 @@ class DataService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deletePendingEvent({
|
||||
required int eventId,
|
||||
}) async {
|
||||
try {
|
||||
await api.delete('/event/pending/$eventId');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete pending event $eventId: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> approvePendingEvent({
|
||||
required int eventId,
|
||||
}) async {
|
||||
try {
|
||||
await api.put('/event/approve/$eventId', null);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to approve pending event $eventId: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> rejectPendingEvent({
|
||||
required int eventId,
|
||||
}) async {
|
||||
try {
|
||||
await api.put('/event/reject/$eventId', null);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to reject pending event $eventId: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_currentUserId = null;
|
||||
_lastLegsFetch = const _LegFetchOptions();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user