Files
mileograph_flutter/lib/objects/objects.dart
Pete Gregory 89b760508d
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
Add new friends system, and sharing legs support
2026-01-03 01:07:08 +00:00

1660 lines
47 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 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<String, dynamic> json) {
DateTime? parseDate(dynamic value) {
if (value is DateTime) return value;
return DateTime.tryParse(value?.toString() ?? '');
}
String pickId(Map<String, dynamic> map, List<String> 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<String, dynamic> 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> 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;
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<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),
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<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,
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<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),
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<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 String? legShareId;
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 = '',
this.legShareId,
});
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']),
legShareId: _asString(json['leg_share_id']),
);
}
}
class LegShareData {
final String id;
final Leg entry;
final Map<String, dynamic> 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<String, dynamic> json) {
final metadataRaw = json['metadata'];
final entryRaw = json['entry'];
final metadata = metadataRaw is Map
? metadataRaw.map((k, v) => MapEntry(k.toString(), v))
: <String, dynamic>{};
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<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 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<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']),
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<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']),
);
}
}