All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m49s
Release / web-build (push) Successful in 6m23s
Release / android-build (push) Successful in 16m56s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
1382 lines
38 KiB
Dart
1382 lines
38 KiB
Dart
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<String> _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<String, dynamic> 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 HomepageStats {
|
|
final double totalMileage;
|
|
final List<YearlyMileage> yearlyMileage;
|
|
final List<LocoSummary> topLocos;
|
|
final List<LeaderboardEntry> leaderboard;
|
|
final List<TripSummary> 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<String, dynamic> 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<String, dynamic> json) => YearlyMileage(
|
|
year: json['year'] is int ? json['year'] as int : int.tryParse('${json['year']}'),
|
|
mileage: _asDouble(json['mileage']),
|
|
);
|
|
}
|
|
|
|
class StatsAbout {
|
|
final Map<int, StatsYear> years;
|
|
|
|
StatsAbout({required this.years});
|
|
|
|
factory StatsAbout.fromJson(Map<String, dynamic> json) {
|
|
final mileageByYear = <int, double>{};
|
|
final classByYear = <int, List<StatsClassMileage>>{};
|
|
final networkByYear = <int, List<StatsNetworkMileage>>{};
|
|
final stationByYear = <int, List<StatsStationVisits>>{};
|
|
final winnersByYear = <int, int>{};
|
|
|
|
void addYearMileage(dynamic entry) {
|
|
if (entry is Map<String, dynamic>) {
|
|
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<String, dynamic>) {
|
|
addYearMileage(entry);
|
|
} else if (entry is Map) {
|
|
addYearMileage(entry
|
|
.map((key, value) => MapEntry(key.toString(), value)));
|
|
}
|
|
}
|
|
}
|
|
|
|
List<StatsClassMileage> parseClassList(dynamic value) {
|
|
if (value is List) {
|
|
return value
|
|
.whereType<Map>()
|
|
.map((e) => StatsClassMileage.fromJson(
|
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
|
.toList();
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
List<StatsNetworkMileage> parseNetworkList(dynamic value) {
|
|
if (value is List) {
|
|
return value
|
|
.whereType<Map>()
|
|
.map((e) => StatsNetworkMileage.fromJson(
|
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
|
.toList();
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
List<StatsStationVisits> parseStationList(dynamic value) {
|
|
if (value is List) {
|
|
return value
|
|
.whereType<Map>()
|
|
.map((e) => StatsStationVisits.fromJson(
|
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
|
.toList();
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
void parseYearMap<T>(
|
|
dynamic source,
|
|
Map<int, T> 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<List<StatsClassMileage>>(
|
|
json['top_classes'],
|
|
classByYear,
|
|
parseClassList,
|
|
);
|
|
parseYearMap<List<StatsNetworkMileage>>(
|
|
json['top_networks'],
|
|
networkByYear,
|
|
parseNetworkList,
|
|
);
|
|
parseYearMap<List<StatsStationVisits>>(
|
|
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 = <int>{
|
|
...mileageByYear.keys,
|
|
...classByYear.keys,
|
|
...networkByYear.keys,
|
|
...stationByYear.keys,
|
|
...winnersByYear.keys,
|
|
}..removeWhere((year) => year == 0);
|
|
|
|
final yearMap = <int, StatsYear>{};
|
|
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<StatsYear> 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<StatsClassMileage> topClasses;
|
|
final List<StatsNetworkMileage> topNetworks;
|
|
final List<StatsStationVisits> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>.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<String, dynamic> 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<LocoAttrVersion> fromGroupedJson(dynamic json) {
|
|
final List<LocoAttrVersion> items = [];
|
|
if (json is Map) {
|
|
json.forEach((key, value) {
|
|
if (value is List) {
|
|
for (final entry in value) {
|
|
if (entry is Map<String, dynamic>) {
|
|
final merged = Map<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<TripLocoStat> 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<TripLocoStat>? 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<String, dynamic> 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<String> route;
|
|
final DateTime beginTime;
|
|
final DateTime? endTime;
|
|
final DateTime? originTime;
|
|
final DateTime? destinationTime;
|
|
final double mileage;
|
|
final int? beginDelayMinutes, endDelayMinutes;
|
|
final List<Loco> 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<String, dynamic> 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>()
|
|
.map((e) => Loco.fromJson(Map<String, dynamic>.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<String, dynamic> json) {
|
|
return RouteError(error: json["error"], msg: json["msg"]);
|
|
}
|
|
}
|
|
|
|
class RouteResult {
|
|
final List<String> inputRoute;
|
|
final List<String> calculatedRoute;
|
|
final List<double> costs;
|
|
final double distance;
|
|
|
|
RouteResult({
|
|
required this.inputRoute,
|
|
required this.calculatedRoute,
|
|
required this.costs,
|
|
required this.distance,
|
|
});
|
|
|
|
factory RouteResult.fromJson(Map<String, dynamic> json) {
|
|
return RouteResult(
|
|
inputRoute: List<String>.from(json['input_route']),
|
|
calculatedRoute: List<String>.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<String, dynamic> 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<Loco> 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<String, dynamic> 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<String>().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<String, dynamic>))
|
|
.toList() ??
|
|
[],
|
|
);
|
|
}
|
|
|
|
class TripDetail {
|
|
final int id;
|
|
final String name;
|
|
final double mileage;
|
|
final int legCount;
|
|
final List<TripLocoStat> locoStats;
|
|
final List<TripLeg> 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<TripLocoStat>? locoStats,
|
|
}) : locoStats = locoStats ?? const [];
|
|
|
|
factory TripDetail.fromJson(Map<String, dynamic> 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<String, dynamic>))
|
|
.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<String, dynamic> json) {
|
|
final locoJson = json['loco'] is Map<String, dynamic>
|
|
? Map<String, dynamic>.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<TripLocoStat> listFromJson(dynamic json) {
|
|
List<dynamic>? 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>()
|
|
.map((e) => TripLocoStat.fromJson(Map<String, dynamic>.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<String>? enumValues;
|
|
|
|
const EventField({
|
|
required this.name,
|
|
required this.display,
|
|
this.type,
|
|
this.enumValues,
|
|
});
|
|
|
|
factory EventField.fromJson(Map<String, dynamic> json) {
|
|
final enumList = json['enum'];
|
|
List<String>? 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 DateTime? createdAt;
|
|
final bool dismissed;
|
|
|
|
UserNotification({
|
|
required this.id,
|
|
required this.title,
|
|
required this.body,
|
|
required this.channel,
|
|
required this.createdAt,
|
|
required this.dismissed,
|
|
});
|
|
|
|
factory UserNotification.fromJson(Map<String, dynamic> 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']),
|
|
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<String, dynamic> 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<String, dynamic>) {
|
|
loco = LocoSummary.fromJson(Map<String, dynamic>.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<String, dynamic> 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<String> 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<String, dynamic> json) {
|
|
final locoJson = json['loco'];
|
|
final loco = locoJson is Map<String, dynamic>
|
|
? LocoSummary.fromJson(Map<String, dynamic>.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']),
|
|
);
|
|
}
|
|
}
|