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
526 lines
16 KiB
Dart
526 lines
16 KiB
Dart
part of 'data_service.dart';
|
|
|
|
extension DataServiceTraction on DataService {
|
|
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {
|
|
await fetchTraction(
|
|
hadOnly: true,
|
|
offset: offset,
|
|
limit: limit,
|
|
append: offset > 0,
|
|
);
|
|
}
|
|
|
|
Future<void> fetchTraction({
|
|
bool hadOnly = false,
|
|
int offset = 0,
|
|
int limit = 100,
|
|
String? locoClass,
|
|
String? locoNumber,
|
|
bool mileageFirst = true,
|
|
bool append = false,
|
|
Map<String, dynamic>? filters,
|
|
}) async {
|
|
_isTractionLoading = true;
|
|
|
|
try {
|
|
final params = StringBuffer('?limit=$limit&offset=$offset');
|
|
if (hadOnly) params.write('&had_only=true');
|
|
if (!mileageFirst) params.write('&mileage_first=false');
|
|
|
|
final payload = <String, dynamic>{};
|
|
if (locoClass != null && locoClass.isNotEmpty) {
|
|
payload['class'] = locoClass;
|
|
}
|
|
if (locoNumber != null && locoNumber.isNotEmpty) {
|
|
payload['number'] = locoNumber;
|
|
}
|
|
if (filters != null) {
|
|
filters.forEach((key, value) {
|
|
if (value == null) return;
|
|
if (value is String && value.trim().isEmpty) return;
|
|
payload[key] = value;
|
|
});
|
|
}
|
|
|
|
final json = await api.post(
|
|
'/locos/search/v2${params.toString()}',
|
|
payload.isEmpty ? null : payload,
|
|
);
|
|
|
|
if (json is List) {
|
|
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
|
|
_traction = append ? [..._traction, ...newItems] : newItems;
|
|
_tractionHasMore = newItems.length >= limit - 1;
|
|
} else {
|
|
throw Exception('Unexpected traction response: $json');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to fetch traction: $e');
|
|
if (!append) {
|
|
_traction = [];
|
|
}
|
|
_tractionHasMore = false;
|
|
} finally {
|
|
_isTractionLoading = false;
|
|
_notifyAsync();
|
|
}
|
|
}
|
|
|
|
Future<List<LocoAttrVersion>> fetchLocoTimeline(
|
|
int locoId, {
|
|
bool includeAllPending = false,
|
|
}) async {
|
|
_isLocoTimelineLoading[locoId] = true;
|
|
_notifyAsync();
|
|
try {
|
|
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] = [];
|
|
return [];
|
|
} finally {
|
|
_isLocoTimelineLoading[locoId] = false;
|
|
_notifyAsync();
|
|
}
|
|
}
|
|
|
|
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);
|
|
final locoClass = payload['class']?.toString();
|
|
if (locoClass != null &&
|
|
locoClass.isNotEmpty &&
|
|
!_locoClasses.contains(locoClass)) {
|
|
_locoClasses = [..._locoClasses, locoClass];
|
|
}
|
|
_notifyAsync();
|
|
return response;
|
|
} catch (e) {
|
|
debugPrint('Failed to create loco: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
_locoClasses = json.map((e) => e.toString()).toList();
|
|
_notifyAsync();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to fetch class list: $e');
|
|
}
|
|
return _locoClasses;
|
|
}
|
|
|
|
Future<void> fetchLatestLocoChanges({
|
|
int limit = 100,
|
|
int offset = 0,
|
|
bool append = false,
|
|
}) async {
|
|
_isLatestLocoChangesLoading = true;
|
|
_notifyAsync();
|
|
try {
|
|
final json =
|
|
await api.get('/loco/changes/latest?limit=$limit&offset=$offset');
|
|
dynamic results = json;
|
|
if (json is Map && json['data'] is List) {
|
|
results = json['data'];
|
|
}
|
|
if (results is List) {
|
|
final parsed = <LocoChange>[];
|
|
for (final item in results) {
|
|
if (item is Map<String, dynamic>) {
|
|
parsed.add(LocoChange.fromJson(item));
|
|
} else if (item is Map) {
|
|
parsed.add(
|
|
LocoChange.fromJson(
|
|
item.map((key, value) => MapEntry(key.toString(), value)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
if (append) {
|
|
_latestLocoChanges = [..._latestLocoChanges, ...parsed];
|
|
} else {
|
|
_latestLocoChanges = parsed;
|
|
}
|
|
final fetchedCount = parsed.length;
|
|
_latestLocoChangesFetched = append
|
|
? offset + fetchedCount
|
|
: fetchedCount;
|
|
_latestLocoChangesHasMore = _latestLocoChangesFetched < 5000;
|
|
} else {
|
|
throw Exception('Unexpected latest loco changes response: $json');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to fetch latest loco changes: $e');
|
|
_latestLocoChanges = [];
|
|
_latestLocoChangesHasMore = false;
|
|
_latestLocoChangesFetched = 0;
|
|
} finally {
|
|
_isLatestLocoChangesLoading = false;
|
|
_notifyAsync();
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
|
|
try {
|
|
final path = Uri.encodeComponent(locoClass);
|
|
final json = await api.get('/loco/class/stats/$path/user');
|
|
if (json is Map) {
|
|
return Map<String, dynamic>.from(json);
|
|
}
|
|
debugPrint('Unexpected class stats response for $locoClass: $json');
|
|
} catch (e) {
|
|
debugPrint('Failed to fetch class stats for $locoClass: $e');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
|
|
String locoClass, {
|
|
bool friends = false,
|
|
}) async {
|
|
try {
|
|
final path = Uri.encodeComponent(locoClass);
|
|
final suffix = friends ? '/friends' : '';
|
|
final json = await api.get('/stats/class/$path/leaderboard$suffix');
|
|
List<dynamic>? list;
|
|
if (json is List) {
|
|
list = json;
|
|
} else if (json is Map) {
|
|
for (final key in ['leaderboard', 'data', 'items', 'results']) {
|
|
final value = json[key];
|
|
if (value is List) {
|
|
list = value;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return list
|
|
?.whereType<Map>()
|
|
.map((e) => LeaderboardEntry.fromJson(
|
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
|
))
|
|
.toList() ??
|
|
const [];
|
|
} catch (e) {
|
|
debugPrint(
|
|
'Failed to fetch class leaderboard for $locoClass (friends=$friends): $e',
|
|
);
|
|
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);
|
|
}
|
|
}
|