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)); } 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(this.username, this.fullName, this.userId, this.email); final String userId; final String username; final String fullName; final String email; } class AuthenticatedUserData extends UserData { const AuthenticatedUserData({ required String userId, required String username, required String fullName, required String email, required this.accessToken, }) : super(username, fullName, userId, email); final String accessToken; } 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( userData['username'] ?? '', userData['full_name'] ?? '', userData['user_id'] ?? '', userData['email'] ?? '', ), ); } } 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; 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, }); 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), ); } 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, }) : 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: json['loco_id'] ?? json['id'] ?? 0, 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), ); } 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; int get locoHadCount => locoStats.length; int get winnersCount => locoStats.where((e) => e.won).length; TripSummary({ required this.tripId, required this.tripName, required this.tripMileage, this.legCount = 0, List? locoStats, }) : locoStats = locoStats ?? const []; factory TripSummary.fromJson(Map json) => 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'], ), ); } class Leg { final int id, tripId, timezone, driving; final String start, end, network, notes, headcode, user; final String origin, destination; final List route; 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 = '', }); factory Leg.fromJson(Map json) { final endTimeRaw = json['leg_end_time']; final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty) ? null : _asDateTime(endTimeRaw); 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']), ); } } 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; 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 DateTime? createdAt; final bool dismissed; UserNotification({ required this.id, required this.title, required this.body, 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']), 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']), ); } }