import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; int _asInt(dynamic value, [int? fallback]) { if (value is int) return value; if (value is num) return value.toInt(); final parsed = int.tryParse(value?.toString() ?? ''); return parsed ?? fallback ?? 0; } double _asDouble(dynamic value, [double fallback = 0]) { if (value is double) return value; if (value is num) return value.toDouble(); final parsed = double.tryParse(value?.toString() ?? ''); return parsed ?? fallback; } String _asString(dynamic value, [String fallback = '']) { final str = value?.toString(); return (str == null) ? fallback : str; } List _asStringList(dynamic value) { if (value is List) { return value.map((e) => e.toString()).toList(); } final trimmed = value?.toString().trim() ?? ''; if (trimmed.isEmpty) return const []; try { final decoded = jsonDecode(trimmed); if (decoded is List) { return decoded.map((e) => e.toString()).toList(); } } catch (_) {} if (trimmed.contains('->')) { return trimmed .split('->') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } if (trimmed.contains(',')) { return trimmed .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } return [trimmed]; } bool _asBool(dynamic value, [bool fallback = false]) { if (value is bool) return value; if (value is num) return value != 0; final lower = value?.toString().toLowerCase(); if (lower == 'true' || lower == 'yes' || lower == '1') return true; if (lower == 'false' || lower == 'no' || lower == '0') return false; return fallback; } DateTime _asDateTime(dynamic value, [DateTime? fallback]) { if (value is DateTime) return value; final parsed = DateTime.tryParse(value?.toString() ?? ''); return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0)); } DateTime? _asNullableDateTime(dynamic value) { if (value == null) return null; if (value is DateTime) return value; return DateTime.tryParse(value.toString()); } int compareTripsByDateDesc( DateTime? aDate, DateTime? bDate, int aId, int bId, ) { if (aDate != null && bDate != null) { final cmp = bDate.compareTo(aDate); if (cmp != 0) return cmp; } else if (aDate != null) { return -1; } else if (bDate != null) { return 1; } return bId.compareTo(aId); } class DestinationObject { const DestinationObject( this.label, this.icon, this.selectedIcon, this.pageWidget, ); final String label; final Widget icon; final Widget selectedIcon; final Widget pageWidget; } class UserData { const UserData({ required this.username, required this.fullName, required this.userId, required this.email, bool? elevated, bool? disabled, }) : elevated = elevated ?? false, disabled = disabled ?? false; final String userId; final String username; final String fullName; final String email; final bool elevated; final bool disabled; } class AuthenticatedUserData extends UserData { const AuthenticatedUserData({ required super.userId, required super.username, required super.fullName, required super.email, bool? elevated, bool? isElevated, bool? disabled, bool? isDisabled, required this.accessToken, }) : super( elevated: (elevated ?? false) || (isElevated ?? false), disabled: (disabled ?? false) || (isDisabled ?? false), ); final String accessToken; bool get isElevated => elevated; } class UserSummary extends UserData { const UserSummary({ required super.username, required super.fullName, required super.userId, required super.email, super.elevated = false, super.disabled = false, }); String get displayName => fullName.isNotEmpty ? fullName : username; factory UserSummary.fromJson(Map json) => UserSummary( username: _asString(json['username'] ?? json['user_name']), fullName: _asString(json['full_name'] ?? json['name']), userId: _asString(json['user_id'] ?? json['id']), email: _asString(json['email']), elevated: _asBool(json['elevated'] ?? json['is_elevated'], false), disabled: _asBool(json['disabled'], false), ); } class Friendship { final String? id; final String status; final String requesterId; final String addresseeId; final UserSummary? requester; final UserSummary? addressee; final DateTime? requestedAt; final DateTime? respondedAt; const Friendship({ required this.id, required this.status, required this.requesterId, required this.addresseeId, this.requester, this.addressee, this.requestedAt, this.respondedAt, }); String get _statusLower => status.toLowerCase(); bool get isNone => _statusLower == 'none'; bool get isPending => _statusLower == 'pending'; bool get isAccepted => _statusLower == 'accepted'; bool get isBlocked => _statusLower == 'blocked'; bool get isDeclined => _statusLower == 'declined' || _statusLower == 'rejected'; factory Friendship.fromJson(Map json) { DateTime? parseDate(dynamic value) { if (value is DateTime) return value; return DateTime.tryParse(value?.toString() ?? ''); } String pickId(Map map, List keys) { for (final key in keys) { final value = map[key]; if (value == null) continue; final str = value.toString(); if (str.isNotEmpty) return str; } return ''; } UserSummary? parseUser(dynamic raw, {String? usernameKey, String? fullKey, String? idKey}) { if (raw is Map) { return UserSummary.fromJson( raw.map((key, value) => MapEntry(key.toString(), value)), ); } final username = usernameKey != null ? _asString(json[usernameKey]) : ''; final fullName = fullKey != null ? _asString(json[fullKey]) : ''; final id = idKey != null ? _asString(json[idKey]) : ''; if (username.isEmpty && fullName.isEmpty && id.isEmpty) return null; return UserSummary( username: username, fullName: fullName, userId: id, email: '', ); } final requesterJson = json['requester'] ?? json['requestor'] ?? json['sender'] ?? json['from_user']; final addresseeJson = json['addressee'] ?? json['receiver'] ?? json['target_user'] ?? json['to_user']; final requester = parseUser( requesterJson, usernameKey: 'requester_username', fullKey: 'requester_full_name', idKey: 'requester_id', ); final addressee = parseUser( addresseeJson, usernameKey: 'addressee_username', fullKey: 'addressee_full_name', idKey: 'addressee_id', ); final requesterUsername = _asString(json['requester_username']); final requesterFullName = _asString(json['requester_full_name']); final addresseeUsername = _asString(json['addressee_username']); final addresseeFullName = _asString(json['addressee_full_name']); final requesterId = requester?.userId.isNotEmpty == true ? requester!.userId : pickId(json, [ 'requester_id', 'requestor_id', 'requestorId', 'sender_id', 'from_user_id', 'from_id', 'user_id', ]); final addresseeId = addressee?.userId.isNotEmpty == true ? addressee!.userId : pickId(json, [ 'addressee_id', 'addresseeId', 'receiver_id', 'target_user_id', 'to_user_id', 'to_id', ]); final normalizedStatus = _asString(json['status'], 'none').trim(); return Friendship( id: pickId(json, ['friendship_id', 'friendshipId', 'id']), status: normalizedStatus.isNotEmpty ? normalizedStatus : 'none', requesterId: requesterId, addresseeId: addresseeId, requester: requester ?? (requesterUsername.isNotEmpty || requesterFullName.isNotEmpty ? UserSummary( username: requesterUsername, fullName: requesterFullName, userId: requesterId, email: '', ) : null), addressee: addressee ?? (addresseeUsername.isNotEmpty || addresseeFullName.isNotEmpty ? UserSummary( username: addresseeUsername, fullName: addresseeFullName, userId: addresseeId, email: '', ) : null), requestedAt: parseDate(json['requested_at'] ?? json['requestedAt']), respondedAt: parseDate(json['responded_at'] ?? json['respondedAt']), ); } factory Friendship.fromStatusJson( Map json, { String? targetUserId, }) { final statusVal = _asString(json['status'], 'none').trim(); if (statusVal.toLowerCase() == 'none') { return Friendship( id: null, status: 'none', requesterId: '', addresseeId: targetUserId ?? '', ); } return Friendship.fromJson(json); } Friendship copyWith({ String? id, String? status, String? requesterId, String? addresseeId, UserSummary? requester, UserSummary? addressee, DateTime? requestedAt, DateTime? respondedAt, }) { return Friendship( id: id ?? this.id, status: status ?? this.status, requesterId: requesterId ?? this.requesterId, addresseeId: addresseeId ?? this.addresseeId, requester: requester ?? this.requester, addressee: addressee ?? this.addressee, requestedAt: requestedAt ?? this.requestedAt, respondedAt: respondedAt ?? this.respondedAt, ); } } class HomepageStats { final double totalMileage; final List yearlyMileage; final List topLocos; final List leaderboard; final List trips; final int legCount; final UserData? user; HomepageStats({ required this.totalMileage, required this.yearlyMileage, required this.topLocos, required this.leaderboard, required this.trips, required this.legCount, this.user, }); factory HomepageStats.fromJson(Map json) { final userData = json['user_data']; final mileageData = json['milage_data']; final totalMileage = mileageData is Map && mileageData['mileage'] != null ? (mileageData['mileage'] as num).toDouble() : 0.0; return HomepageStats( totalMileage: totalMileage, yearlyMileage: (json['yearly_mileage'] as List? ?? []) .map((e) => YearlyMileage.fromJson(e)) .toList(), topLocos: (json['top_locos'] as List? ?? []) .map((e) => LocoSummary.fromJson(e)) .toList(), leaderboard: (json['leaderboard_data'] as List? ?? []) .map((e) => LeaderboardEntry.fromJson(e)) .toList(), trips: (json['trip_data'] as List? ?? []) .map((e) => TripSummary.fromJson(e)) .toList(), legCount: _asInt( json['leg_count'], (json['trip_legs'] as List?)?.length ?? 0, ), user: userData == null ? null : UserData( username: userData['username'] ?? '', fullName: userData['full_name'] ?? '', userId: userData['user_id'] ?? '', email: userData['email'] ?? '', elevated: _asBool(userData['elevated'] ?? userData['is_elevated'], false), disabled: _asBool(userData['disabled'], false), ), ); } } class YearlyMileage { final int? year; final double mileage; YearlyMileage({this.year, required this.mileage}); factory YearlyMileage.fromJson(Map json) => YearlyMileage( year: json['year'] is int ? json['year'] as int : int.tryParse('${json['year']}'), mileage: _asDouble(json['mileage']), ); } class StatsAbout { final Map years; StatsAbout({required this.years}); factory StatsAbout.fromJson(Map json) { final mileageByYear = {}; final classByYear = >{}; final networkByYear = >{}; final stationByYear = >{}; final winnersByYear = {}; void addYearMileage(dynamic entry) { if (entry is Map) { final year = entry['year'] is int ? entry['year'] as int : int.tryParse('${entry['year']}'); if (year != null) { mileageByYear[year] = _asDouble(entry['mileage']); } } } if (json['year_mileages'] is List) { for (final entry in json['year_mileages']) { if (entry is Map) { addYearMileage(entry); } else if (entry is Map) { addYearMileage(entry .map((key, value) => MapEntry(key.toString(), value))); } } } List parseClassList(dynamic value) { if (value is List) { return value .whereType() .map((e) => StatsClassMileage.fromJson( e.map((key, value) => MapEntry(key.toString(), value)))) .toList(); } return const []; } List parseNetworkList(dynamic value) { if (value is List) { return value .whereType() .map((e) => StatsNetworkMileage.fromJson( e.map((key, value) => MapEntry(key.toString(), value)))) .toList(); } return const []; } List parseStationList(dynamic value) { if (value is List) { return value .whereType() .map((e) => StatsStationVisits.fromJson( e.map((key, value) => MapEntry(key.toString(), value)))) .toList(); } return const []; } void parseYearMap( dynamic source, Map target, T Function(dynamic value) mapper, ) { if (source is Map) { source.forEach((key, value) { final year = int.tryParse(key.toString()); if (year == null) return; target[year] = mapper(value); }); } } parseYearMap>( json['top_classes'], classByYear, parseClassList, ); parseYearMap>( json['top_networks'], networkByYear, parseNetworkList, ); parseYearMap>( json['top_stations'], stationByYear, parseStationList, ); if (json['year_winners'] is Map) { (json['year_winners'] as Map).forEach((key, value) { final year = int.tryParse(key.toString()); if (year == null) return; if (value is List) { winnersByYear[year] = value.length; } }); } final years = { ...mileageByYear.keys, ...classByYear.keys, ...networkByYear.keys, ...stationByYear.keys, ...winnersByYear.keys, }..removeWhere((year) => year == 0); final yearMap = {}; for (final year in years) { yearMap[year] = StatsYear( year: year, mileage: mileageByYear[year] ?? 0, topClasses: classByYear[year] ?? const [], topNetworks: networkByYear[year] ?? const [], topStations: stationByYear[year] ?? const [], winnerCount: winnersByYear[year] ?? 0, ); } return StatsAbout(years: yearMap); } List get sortedYears { final list = years.values.toList(); list.sort((a, b) => b.year.compareTo(a.year)); return list; } } class StatsYear { final int year; final double mileage; final List topClasses; final List topNetworks; final List topStations; final int winnerCount; StatsYear({ required this.year, required this.mileage, required this.topClasses, required this.topNetworks, required this.topStations, required this.winnerCount, }); } class StatsClassMileage { final String locoClass; final double mileage; StatsClassMileage({ required this.locoClass, required this.mileage, }); factory StatsClassMileage.fromJson(Map json) => StatsClassMileage( locoClass: _asString(json['loco_class'], 'Unknown'), mileage: _asDouble(json['mileage']), ); } class StatsNetworkMileage { final String network; final double mileage; StatsNetworkMileage({ required this.network, required this.mileage, }); factory StatsNetworkMileage.fromJson(Map json) => StatsNetworkMileage( network: _asString(json['network'], 'Unknown'), mileage: _asDouble(json['mileage']), ); } class StatsStationVisits { final String station; final int visits; StatsStationVisits({ required this.station, required this.visits, }); factory StatsStationVisits.fromJson(Map json) => StatsStationVisits( station: _asString(json['station'], 'Unknown'), visits: _asInt(json['visits']), ); } class Loco { final int id; final String type, number, locoClass; final String? name, operator, notes, evn; final bool powering; final int allocPos; Loco({ required this.id, required this.type, required this.number, required this.name, required this.locoClass, required this.operator, this.notes, this.evn, this.powering = true, this.allocPos = 0, }); factory Loco.fromJson(Map json) => Loco( id: json['loco_id'], type: json['type'], number: json['number'], name: json['name'] ?? "", locoClass: json['class'], operator: json['operator'], notes: json['notes'], evn: json['evn'], powering: _asBool(json['alloc_powering'] ?? json['powering'], true), allocPos: _asInt(json['alloc_pos'], 0), ); } class LocoSummary extends Loco { final double? mileage; final int? journeys; final int? trips; final String? status; final String? domain; final String? owner; final String? livery; final String? location; final Map extra; LocoSummary({ required int locoId, required String locoType, required String locoNumber, required String locoName, required super.locoClass, required String locoOperator, String? locoNotes, String? locoEvn, this.mileage, this.journeys, this.trips, this.status, this.domain, this.owner, this.livery, this.location, Map? extra, super.powering = true, super.allocPos = 0, }) : extra = extra ?? const {}, super( id: locoId, type: locoType, number: locoNumber, name: locoName, operator: locoOperator, notes: locoNotes, evn: locoEvn, ); factory LocoSummary.fromJson(Map json) => LocoSummary( locoId: _asInt(json['loco_id'] ?? json['id']), locoType: json['type'] ?? json['loco_type'] ?? '', locoNumber: json['number'] ?? json['loco_number'] ?? '', locoName: json['name'] ?? json['loco_name'] ?? "", locoClass: json['class'] ?? json['loco_class'] ?? '', locoOperator: json['operator'] ?? json['loco_operator'] ?? '', locoNotes: json['notes'], locoEvn: json['evn'] ?? json['loco_evn'], mileage: ((json['loco_mileage'] ?? json['mileage']) as num?)?.toDouble() ?? 0, journeys: (json['loco_journeys'] ?? json['journeys'] ?? 0) is num ? (json['loco_journeys'] ?? json['journeys'] ?? 0).toInt() : 0, trips: (json['loco_trips'] ?? json['trips']) is num ? (json['loco_trips'] ?? json['trips']).toInt() : null, status: json['status'] ?? json['loco_status'], domain: json['domain'], owner: json['owner'] ?? json['loco_owner'], livery: json['livery'], location: json['location'], extra: Map.from(json), powering: _asBool(json['alloc_powering'] ?? json['powering'], true), allocPos: _asInt(json['alloc_pos'], 0), ); } class LocoAttrVersion { final String attrCode; final int? versionId; final int locoId; final int? attrTypeId; final String? valueStr; final int? valueInt; final DateTime? valueDate; final bool? valueBool; final String? valueEnum; final DateTime? validFrom; final DateTime? validTo; final DateTime? txnFrom; final DateTime? txnTo; final String? suggestedBy; final String? approvedBy; final DateTime? approvedAt; final int? sourceEventId; final String? precisionLevel; final String? maskedValidFrom; final dynamic valueNorm; const LocoAttrVersion({ required this.attrCode, required this.locoId, this.versionId, this.attrTypeId, this.valueStr, this.valueInt, this.valueDate, this.valueBool, this.valueEnum, this.validFrom, this.validTo, this.txnFrom, this.txnTo, this.suggestedBy, this.approvedBy, this.approvedAt, this.sourceEventId, this.precisionLevel, this.maskedValidFrom, this.valueNorm, }); factory LocoAttrVersion.fromJson(Map json) { return LocoAttrVersion( attrCode: json['attr_code']?.toString() ?? '', locoId: (json['loco_id'] as num?)?.toInt() ?? 0, versionId: (json['loco_attr_v_id'] as num?)?.toInt(), attrTypeId: (json['attr_type_id'] as num?)?.toInt(), valueStr: json['value_str']?.toString(), valueInt: (json['value_int'] as num?)?.toInt(), valueDate: _parseDate(json['value_date']), valueBool: _parseBool(json['value_bool']), valueEnum: json['value_enum']?.toString(), validFrom: _parseDate(json['valid_from']), validTo: _parseDate(json['valid_to']), txnFrom: _parseDate(json['txn_from']), txnTo: _parseDate(json['txn_to']), suggestedBy: json['suggested_by']?.toString(), approvedBy: json['approved_by']?.toString(), approvedAt: _parseDate(json['approved_at']), sourceEventId: (json['source_event_id'] as num?)?.toInt(), precisionLevel: json['precision_level']?.toString(), maskedValidFrom: json['masked_valid_from']?.toString(), valueNorm: json['value_norm'], ); } static DateTime? _parseDate(dynamic value) { if (value == null) return null; if (value is DateTime) return value; return DateTime.tryParse(value.toString()); } static bool? _parseBool(dynamic value) { if (value == null) return null; if (value is bool) return value; if (value is num) return value != 0; final str = value.toString().toLowerCase(); if (['true', '1', 'yes'].contains(str)) return true; if (['false', '0', 'no'].contains(str)) return false; return null; } static List fromGroupedJson(dynamic json) { final List items = []; if (json is Map) { json.forEach((key, value) { if (value is List) { for (final entry in value) { if (entry is Map) { final merged = Map.from(entry); merged.putIfAbsent('attr_code', () => key); items.add(LocoAttrVersion.fromJson(merged)); } } } }); } items.sort( (a, b) { final aDate = a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0); final bDate = b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0); final dateCompare = aDate.compareTo(bDate); if (dateCompare != 0) return dateCompare; return a.attrCode.compareTo(b.attrCode); }, ); return items; } String get valueLabel { if (valueStr != null && valueStr!.isNotEmpty) return valueStr!; if (valueEnum != null && valueEnum!.isNotEmpty) return valueEnum!; if (valueInt != null) return valueInt!.toString(); if (valueBool != null) return valueBool! ? 'Yes' : 'No'; if (valueDate != null) return DateFormat('yyyy-MM-dd').format(valueDate!); if (valueNorm != null && valueNorm.toString().isNotEmpty) { return valueNorm.toString(); } return '—'; } String get validityRange { final start = maskedValidFrom ?? _formatDate(validFrom) ?? 'Unknown'; final end = _formatDate(validTo, fallback: 'Present') ?? 'Present'; return '$start → $end'; } String? _formatDate(DateTime? value, {String? fallback}) { if (value == null) return fallback; return DateFormat('yyyy-MM-dd').format(value); } } class LocoChange { final int locoId; final String locoClass; final String locoNumber; final String locoName; final String attrCode; final String attrDisplay; final String valueDisplay; final DateTime? validFrom; final DateTime? approvedAt; final String approvedBy; const LocoChange({ required this.locoId, required this.locoClass, required this.locoNumber, required this.locoName, required this.attrCode, required this.attrDisplay, required this.valueDisplay, required this.validFrom, required this.approvedAt, required this.approvedBy, }); factory LocoChange.fromJson(Map json) { String cleanValue(dynamic value) { final str = value?.toString().trim() ?? ''; if (str.isEmpty || str == '-' || str == '?') return ''; return str; } final valueLabel = json['value_norm'] ?? json['value_display'] ?? json['value_label'] ?? json['value_str'] ?? json['value_enum'] ?? json['value_norm'] ?? json['value']; final approvedRaw = json['approved_at'] ?? json['approvedAt']; final validFromRaw = json['valid_from'] ?? json['validFrom']; return LocoChange( locoId: _asInt(json['loco_id']), locoClass: cleanValue(json['loco_class']), locoNumber: cleanValue(json['loco_number']), locoName: cleanValue(json['loco_name']), attrCode: _asString(json['attr_code']), attrDisplay: cleanValue(json['attr_display']), valueDisplay: cleanValue(valueLabel), validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''), approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''), approvedBy: cleanValue(json['approved_by']), ); } String get locoLabel { final parts = [locoClass, locoNumber] .map((e) => e.trim()) .where((e) => e.isNotEmpty && e != '-') .toList(); final label = parts.join(' '); if (label.isEmpty) return locoName.isNotEmpty ? locoName : 'Loco $locoId'; return locoName.trim().isEmpty ? label : '$label — ${locoName.trim()}'; } String get changeLabel => _cleanLabel(attrDisplay).isNotEmpty ? _cleanLabel(attrDisplay) : _cleanLabel(attrCode).toUpperCase(); String get approvedDateLabel { final date = approvedAt ?? validFrom; if (date == null) return 'Pending date'; return DateFormat('yyyy-MM-dd').format(date); } String get valueLabel { final value = _cleanLabel(valueDisplay); if (value.isNotEmpty) return value; return 'Unknown value'; } String _cleanLabel(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) return ''; if (trimmed == '-' || trimmed == '?') return ''; return trimmed; } } class LeaderboardEntry { final String userId, username, userFullName; final double mileage; LeaderboardEntry({ required this.userId, required this.username, required this.userFullName, required this.mileage, }); factory LeaderboardEntry.fromJson(Map json) => LeaderboardEntry( userId: _asString(json['user_id']), username: _asString(json['username']), userFullName: _asString(json['user_full_name']), mileage: _asDouble(json['mileage']), ); } class TripSummary { final int tripId; final String tripName; final double tripMileage; final int legCount; final List locoStats; final DateTime? startDate; final DateTime? endDate; int get locoHadCount => locoStats.length; int get winnersCount => locoStats.where((e) => e.won).length; DateTime? get primaryDate => endDate ?? startDate; TripSummary({ required this.tripId, required this.tripName, required this.tripMileage, this.legCount = 0, List? locoStats, this.startDate, this.endDate, }) : locoStats = locoStats ?? const []; static int compareByDateDesc(TripSummary a, TripSummary b) => compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.tripId, b.tripId); factory TripSummary.fromJson(Map json) { DateTime? startDate; DateTime? endDate; DateTime? parseDate(dynamic value) => _asNullableDateTime(value); for (final key in [ 'trip_begin_time', 'trip_start', 'trip_start_time', 'trip_date', 'start_date', 'date', ]) { startDate ??= parseDate(json[key]); } for (final key in [ 'trip_end_time', 'trip_finish_time', 'trip_end', 'end_date', ]) { endDate ??= parseDate(json[key]); } if (json['trip_legs'] is List) { for (final leg in json['trip_legs'] as List) { DateTime? begin; if (leg is TripLeg) { begin = leg.beginTime; } else if (leg is Map) { begin = parseDate(leg['leg_begin_time']); } if (begin == null) continue; if (startDate == null || begin.isBefore(startDate)) { startDate = begin; } if (endDate == null || begin.isAfter(endDate)) { endDate = begin; } } } return TripSummary( tripId: _asInt(json['trip_id']), tripName: _asString(json['trip_name']), tripMileage: _asDouble(json['trip_mileage']), legCount: _asInt( json['leg_count'], (json['trip_legs'] as List?)?.length ?? 0, ), locoStats: TripLocoStat.listFromJson( json['stats'] ?? json['trip_locos'] ?? json['locos'], ), startDate: startDate, endDate: endDate, ); } } class Leg { final int id, tripId, timezone, driving; final String start, end, network, notes, headcode, user; final String origin, destination; final List route; final String? legShareId; final LegShareMeta? sharedFrom; final List sharedTo; final DateTime beginTime; final DateTime? endTime; final DateTime? originTime; final DateTime? destinationTime; final double mileage; final int? beginDelayMinutes, endDelayMinutes; final List locos; Leg({ required this.id, required this.tripId, required this.start, required this.end, required this.beginTime, required this.timezone, required this.network, required this.route, required this.mileage, required this.notes, required this.headcode, required this.driving, required this.user, required this.locos, this.endTime, this.originTime, this.destinationTime, this.beginDelayMinutes, this.endDelayMinutes, this.origin = '', this.destination = '', this.legShareId, this.sharedFrom, this.sharedTo = const [], }); factory Leg.fromJson(Map json) { final endTimeRaw = json['leg_end_time']; final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty) ? null : _asDateTime(endTimeRaw); LegShareMeta? sharedFrom; final sharedFromJson = json['shared_from']; if (sharedFromJson is Map) { sharedFrom = LegShareMeta.fromJson( sharedFromJson.map((k, v) => MapEntry(k.toString(), v)), ); } List sharedTo = const []; final sharedToJson = json['shared_to']; if (sharedToJson is List) { sharedTo = sharedToJson .whereType() .map( (e) => LegShareMeta.fromJson( e.map((k, v) => MapEntry(k.toString(), v)), ), ) .toList(); } return Leg( id: _asInt(json['leg_id']), tripId: _asInt(json['leg_trip']), start: _asString(json['leg_start']), end: _asString(json['leg_end']), beginTime: _asDateTime(json['leg_begin_time']), endTime: parsedEndTime, originTime: json['leg_origin_time'] == null ? null : _asDateTime(json['leg_origin_time']), destinationTime: json['leg_destination_time'] == null ? null : _asDateTime(json['leg_destination_time']), timezone: _asInt(json['leg_timezone']), network: _asString(json['leg_network']), route: _asStringList(json['leg_route']), mileage: _asDouble(json['leg_mileage']), notes: _asString(json['leg_notes']), headcode: _asString(json['leg_headcode']), driving: _asInt(json['leg_driving']), user: _asString(json['leg_user']), locos: (json['locos'] is List ? (json['locos'] as List) : const []) .whereType() .map((e) => Loco.fromJson(Map.from(e))) .toList(), beginDelayMinutes: json['leg_begin_delay'] == null ? null : _asInt(json['leg_begin_delay']), endDelayMinutes: json['leg_end_delay'] == null ? null : _asInt(json['leg_end_delay']), origin: _asString(json['leg_origin']), destination: _asString(json['leg_destination']), legShareId: _asString(json['leg_share_id']), sharedFrom: sharedFrom, sharedTo: sharedTo, ); } } class LegShareMeta { final int? legShareId; final int? legId; final String sharedToUserId; final String sharedByUserId; final String status; final DateTime? respondedAt; final bool? acceptedEdits; final DateTime? sharedAt; final String sharedToUsername; final String sharedToFullName; final String sharedByUsername; final String sharedByFullName; LegShareMeta({ this.legShareId, this.legId, required this.sharedToUserId, required this.sharedByUserId, required this.status, this.respondedAt, this.acceptedEdits, this.sharedAt, this.sharedToUsername = '', this.sharedToFullName = '', this.sharedByUsername = '', this.sharedByFullName = '', }); factory LegShareMeta.fromJson(Map json) { DateTime? parseDate(dynamic raw) { if (raw == null) return null; if (raw is DateTime) return raw; return DateTime.tryParse(raw.toString()); } return LegShareMeta( legShareId: _asInt(json['leg_share_id']), legId: _asInt(json['leg_id']), sharedToUserId: _asString(json['shared_to_user_id']), sharedByUserId: _asString(json['shared_by_user_id']), status: _asString(json['status'], 'pending'), respondedAt: parseDate(json['responded_at']), acceptedEdits: json['accepted_edits'] == null ? null : _asBool(json['accepted_edits'], false), sharedAt: parseDate(json['shared_at']), sharedToUsername: _asString(json['shared_to_username']), sharedToFullName: _asString(json['shared_to_full_name']), sharedByUsername: _asString(json['shared_by_username']), sharedByFullName: _asString(json['shared_by_full_name']), ); } String get sharedFromDisplay => sharedByFullName.isNotEmpty ? sharedByFullName : sharedByUsername; String get sharedToDisplay => sharedToFullName.isNotEmpty ? sharedToFullName : sharedToUsername; } class LegShareData { final String id; final Leg entry; final Map metadata; final UserSummary? requester; final UserSummary? addressee; final DateTime? sharedAt; final String status; final int? notificationId; LegShareData({ required this.id, required this.entry, required this.metadata, this.requester, this.addressee, this.sharedAt, this.status = 'pending', this.notificationId, }); LegShareData copyWith({int? notificationId}) { return LegShareData( id: id, entry: entry, metadata: metadata, requester: requester, addressee: addressee, sharedAt: sharedAt, status: status, notificationId: notificationId ?? this.notificationId, ); } String get sharedFromName => requester?.displayName.isNotEmpty == true ? requester!.displayName : ''; factory LegShareData.fromJson(Map json) { final metadataRaw = json['metadata']; final entryRaw = json['entry']; final metadata = metadataRaw is Map ? metadataRaw.map((k, v) => MapEntry(k.toString(), v)) : {}; final shareId = _asString( metadata['leg_share_id'] ?? metadata['share_id'] ?? json['leg_share_id'], ); final requester = UserSummary.fromJson({ "user_id": metadata['requester_id'] ?? metadata['from_user_id'] ?? '', "username": metadata['requester_username'] ?? '', "full_name": metadata['requester_full_name'] ?? '', "email": '', }); final addressee = UserSummary.fromJson({ "user_id": metadata['addressee_id'] ?? metadata['target_user_id'] ?? '', "username": metadata['addressee_username'] ?? '', "full_name": metadata['addressee_full_name'] ?? '', "email": '', }); DateTime? sharedAt; for (final key in ['requested_at', 'created_at', 'shared_at']) { final value = metadata[key]; if (value != null) { sharedAt = DateTime.tryParse(value.toString()); if (sharedAt != null) break; } } final entryMap = entryRaw is Map ? entryRaw.map((k, v) => MapEntry(k.toString(), v)) : json.map((k, v) => MapEntry(k.toString(), v)); final entryObj = Leg.fromJson(entryMap); final resolvedId = shareId.isNotEmpty ? shareId : entryObj.legShareId ?? _asString(json['leg_share_id']); return LegShareData( id: resolvedId, entry: entryObj, metadata: metadata, requester: requester.userId.isEmpty ? null : requester, addressee: addressee.userId.isEmpty ? null : addressee, sharedAt: sharedAt, status: _asString(metadata['status'], 'pending'), ); } } class RouteError { final String error; final String msg; RouteError({required this.error, required this.msg}); factory RouteError.fromJson(Map json) { return RouteError(error: json["error"], msg: json["msg"]); } } class RouteResult { final List inputRoute; final List calculatedRoute; final List costs; final double distance; RouteResult({ required this.inputRoute, required this.calculatedRoute, required this.costs, required this.distance, }); factory RouteResult.fromJson(Map json) { return RouteResult( inputRoute: List.from(json['input_route']), calculatedRoute: List.from(json['calculated_route']), costs: (json['costs'] as List).map((e) => (e as num).toDouble()).toList(), distance: (json['distance'] as num).toDouble(), ); } } class Station { final int id; final String name; final String network; final String country; Station({ required this.id, required this.name, required this.network, required this.country, }); factory Station.fromJson(Map json) => Station( id: _asInt(json['id']), name: _asString(json['name']), network: _asString(json['network']), country: _asString(json['country']), ); } class TripLeg { final int? id; final String start; final String end; final DateTime? beginTime; final String? network; final String? route; final double? mileage; final String? notes; final List locos; TripLeg({ required this.id, required this.start, required this.end, required this.beginTime, required this.network, required this.route, required this.mileage, required this.notes, required this.locos, }); factory TripLeg.fromJson(Map json) => TripLeg( id: _asInt(json['leg_id']), start: _asString(json['leg_start']), end: _asString(json['leg_end']), beginTime: json['leg_begin_time'] != null && json['leg_begin_time'] is String ? DateTime.tryParse(json['leg_begin_time']) : (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null), network: _asString(json['leg_network'], ''), route: () { final route = json['leg_route']; if (route is List) { return route.whereType().join(' → '); } return _asString(route, ''); }(), mileage: (json['leg_mileage'] as num?)?.toDouble(), notes: _asString(json['leg_notes'], ''), locos: (json['locos'] as List?) ?.map((e) => Loco.fromJson(e as Map)) .toList() ?? [], ); } class TripDetail { final int id; final String name; final double mileage; final int legCount; final List locoStats; final List legs; int get locoHadCount => locoStats.length; int get winnersCount => locoStats.where((e) => e.won).length; DateTime? get startDate { DateTime? earliest; for (final leg in legs) { final begin = leg.beginTime; if (begin == null) continue; if (earliest == null || begin.isBefore(earliest)) { earliest = begin; } } return earliest; } DateTime? get endDate { DateTime? latest; for (final leg in legs) { final begin = leg.beginTime; if (begin == null) continue; if (latest == null || begin.isAfter(latest)) { latest = begin; } } return latest; } DateTime? get primaryDate => endDate ?? startDate; static int compareByDateDesc(TripDetail a, TripDetail b) => compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.id, b.id); TripDetail({ required this.id, required this.name, required this.mileage, required this.legCount, required this.legs, List? locoStats, }) : locoStats = locoStats ?? const []; factory TripDetail.fromJson(Map json) => TripDetail( id: json['trip_id'] ?? json['id'] ?? 0, name: json['trip_name'] ?? '', mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0, legCount: _asInt( json['leg_count'], (json['trip_legs'] as List?)?.length ?? 0, ), locoStats: TripLocoStat.listFromJson( json['stats'] ?? json['trip_locos'] ?? json['locos'], ), legs: (json['trip_legs'] as List?) ?.map((e) => TripLeg.fromJson(e as Map)) .toList() ?? [], ); } class TripLocoStat { final String locoClass; final String number; final String? name; final bool won; TripLocoStat({ required this.locoClass, required this.number, required this.won, this.name, }); factory TripLocoStat.fromJson(Map json) { final locoJson = json['loco'] is Map ? Map.from(json['loco'] as Map) : null; final locoClass = json['loco_class'] ?? json['class'] ?? locoJson?['class'] ?? locoJson?['loco_class'] ?? ''; final locoNumber = json['loco_number'] ?? json['number'] ?? locoJson?['number'] ?? locoJson?['loco_number'] ?? ''; final locoName = json['loco_name'] ?? json['name'] ?? locoJson?['name'] ?? locoJson?['loco_name']; final wonValue = json['won'] ?? json['result'] ?? json['winner']; final won = _parseWonFlag(wonValue); return TripLocoStat( locoClass: locoClass, number: locoNumber, name: locoName, won: won, ); } static List listFromJson(dynamic json) { List? list; if (json is List) { list = json.expand((e) => e is List ? e : [e]).toList(); } else if (json is Map) { for (final key in ['locos', 'stats', 'data', 'trip_locos']) { final candidate = json[key]; if (candidate is List) { list = candidate.expand((e) => e is List ? e : [e]).toList(); break; } } } if (list == null) return const []; return list .whereType() .map((e) => TripLocoStat.fromJson(Map.from(e))) .toList(); } static bool _parseWonFlag(dynamic value) { if (value == null) return false; if (value is bool) return value; if (value is num) return value != 0; final normalized = value.toString().toLowerCase(); return ['1', 'true', 'win', 'won', 'yes', 'w'].contains(normalized); } } class EventField { final String name; final String display; final String? type; final List? enumValues; const EventField({ required this.name, required this.display, this.type, this.enumValues, }); factory EventField.fromJson(Map json) { final enumList = json['enum']; List? enumValues; if (enumList is List) { enumValues = enumList.map((e) => e.toString()).toList(); } final baseName = json['name']?.toString() ?? json['field']?.toString() ?? ''; final display = json['field']?.toString() ?? baseName; return EventField( name: baseName, display: display, type: json['type']?.toString(), enumValues: enumValues, ); } } class UserNotification { final int id; final String title; final String body; final String channel; final String type; final DateTime? createdAt; final bool dismissed; UserNotification({ required this.id, required this.title, required this.body, required this.channel, required this.type, required this.createdAt, required this.dismissed, }); factory UserNotification.fromJson(Map json) { final created = json['created_at'] ?? json['createdAt']; DateTime? createdAt; if (created is String) { createdAt = DateTime.tryParse(created); } else if (created is DateTime) { createdAt = created; } return UserNotification( id: _asInt(json['notification_id'] ?? json['id']), title: _asString(json['title']), body: _asString(json['body']), channel: _asString(json['channel']), type: _asString(json['type'] ?? json['notification_type']), createdAt: createdAt, dismissed: _asBool(json['dismissed'] ?? false, false), ); } } class BadgeAward { final int id; final int badgeId; final String badgeCode; final String badgeTier; final String? scopeValue; final DateTime? awardedAt; final LocoSummary? loco; BadgeAward({ required this.id, required this.badgeId, required this.badgeCode, required this.badgeTier, this.scopeValue, this.awardedAt, this.loco, }); factory BadgeAward.fromJson(Map json) { final awarded = json['awarded_at'] ?? json['awardedAt']; DateTime? awardedAt; if (awarded is String) { awardedAt = DateTime.tryParse(awarded); } else if (awarded is DateTime) { awardedAt = awarded; } final locoJson = json['loco']; LocoSummary? loco; if (locoJson is Map) { loco = LocoSummary.fromJson(Map.from(locoJson)); } return BadgeAward( id: _asInt(json['award_id'] ?? json['id']), badgeId: _asInt(json['badge_id'] ?? 0), badgeCode: _asString(json['badge_code']), badgeTier: _asString(json['badge_tier']), scopeValue: _asString(json['scope_value']), awardedAt: awardedAt, loco: loco, ); } } class ClassClearanceProgress { final String className; final int completed; final int total; final double percentComplete; ClassClearanceProgress({ required this.className, required this.completed, required this.total, required this.percentComplete, }); factory ClassClearanceProgress.fromJson(Map json) { final name = _asString(json['class'] ?? json['class_name'] ?? json['name']); final completed = _asInt( json['completed'] ?? json['done'] ?? json['count'] ?? json['had'], ); final total = _asInt(json['total'] ?? json['required'] ?? json['goal']); double percent = _asDouble( json['percent_complete'] ?? json['percent'] ?? json['completion'] ?? json['pct'], ); if (percent == 0 && total > 0) { percent = (completed / total) * 100; } return ClassClearanceProgress( className: name.isNotEmpty ? name : 'Class', completed: completed, total: total, percentComplete: percent, ); } } class LocoClearanceProgress { final LocoSummary loco; final double mileage; final double required; final String nextTier; final List awardedTiers; final double percent; LocoClearanceProgress({ required this.loco, required this.mileage, required this.required, required this.nextTier, required this.awardedTiers, required this.percent, }); factory LocoClearanceProgress.fromJson(Map json) { final locoJson = json['loco']; final loco = locoJson is Map ? LocoSummary.fromJson(Map.from(locoJson)) : LocoSummary( locoId: _asInt(json['loco_id']), locoType: _asString(json['loco_type']), locoNumber: _asString(json['loco_number']), locoName: _asString(json['loco_name']), locoClass: _asString(json['loco_class']), locoOperator: _asString(json['operator']), powering: true, locoNotes: null, locoEvn: null, ); return LocoClearanceProgress( loco: loco, mileage: _asDouble(json['mileage']), required: _asDouble(json['required']), nextTier: _asString(json['next_tier']), awardedTiers: (json['awarded_tiers'] as List? ?? []) .map((e) => e.toString()) .toList(), percent: _asDouble(json['percent']), ); } }