implemented RMV public transit module including trip search, station departures, and nearby stop lookup, added "Marianum Connect" API integration with bearer token authentication and auto-refresh logic, integrated geolocator for location-based station search, added persistent storage for favorite stations and recent trip queries, and implemented comprehensive UI for journey details, trip results, and disruption alerts

This commit is contained in:
2026-05-20 19:08:05 +02:00
parent f185b3273a
commit 067012cc84
61 changed files with 7885 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
import 'dart:convert';
import '../../connect_api.dart';
import 'login_request.dart';
import 'login_response.dart';
class Login extends ConnectApi<LoginResponse> {
final LoginRequest payload;
Login(this.payload) : super('auth/login');
@override
bool get requiresAuth => false;
@override
ConnectHttpMethod get method => ConnectHttpMethod.post;
@override
Object? get body => payload.toJson();
@override
LoginResponse assemble(String raw) =>
LoginResponse.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'login_request.g.dart';
@JsonSerializable()
class LoginRequest {
final String username;
final String password;
final String tokenName;
LoginRequest({
required this.username,
required this.password,
required this.tokenName,
});
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
}
@@ -0,0 +1,20 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_request.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginRequest _$LoginRequestFromJson(Map<String, dynamic> json) => LoginRequest(
username: json['username'] as String,
password: json['password'] as String,
tokenName: json['tokenName'] as String,
);
Map<String, dynamic> _$LoginRequestToJson(LoginRequest instance) =>
<String, dynamic>{
'username': instance.username,
'password': instance.password,
'tokenName': instance.tokenName,
};
@@ -0,0 +1,65 @@
/// Hand-rolled to be tolerant of the actual server payload: only [token] is
/// load-bearing. `expiresAt` may be `null` (server-issued tokens without an
/// explicit expiry); every other field shape is also tolerated so a stray
/// rename on the backend does not break login for everyone.
class LoginResponse {
final String token;
final String? tokenId;
/// `null` when the backend did not provide an expiry. In that case the
/// token is treated as long-lived; callers should refresh on 401.
final DateTime? expiresAt;
final ConnectUserDto? user;
LoginResponse({
required this.token,
required this.tokenId,
required this.expiresAt,
required this.user,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) {
final token = json['token'];
if (token is! String || token.isEmpty) {
throw const FormatException('login response missing "token" string');
}
final expiresRaw = json['expiresAt'];
final expires = expiresRaw is String ? DateTime.tryParse(expiresRaw) : null;
final userJson = json['user'];
return LoginResponse(
token: token,
tokenId: json['tokenId']?.toString(),
expiresAt: expires,
user: userJson is Map<String, dynamic>
? ConnectUserDto.fromJson(userJson)
: null,
);
}
}
class ConnectUserDto {
final String? id;
final String? username;
final String? firstName;
final String? lastName;
final String? userType;
final String? className;
ConnectUserDto({
this.id,
this.username,
this.firstName,
this.lastName,
this.userType,
this.className,
});
factory ConnectUserDto.fromJson(Map<String, dynamic> json) => ConnectUserDto(
id: json['id']?.toString(),
username: json['username']?.toString(),
firstName: json['firstName']?.toString(),
lastName: json['lastName']?.toString(),
userType: json['userType']?.toString(),
className: json['className']?.toString(),
);
}
+179
View File
@@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../api_request.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import 'connect_auth_store.dart';
import 'connect_endpoint.dart';
import 'errors/connect_exception.dart';
import 'errors/rmv_rate_limited_exception.dart';
import 'errors/rmv_upstream_exception.dart';
enum ConnectHttpMethod { get, post }
/// Mirrors the [MhslApi] pattern: each endpoint subclasses this, declares the
/// subpath/query/body, and implements [assemble]. Handles bearer-token
/// injection (via [ConnectAuthStore]), one transparent 401-retry after a
/// fresh login, and turns the structured `RmvController.wrap` error strings
/// into typed exceptions.
abstract class ConnectApi<T> extends ApiRequest {
final String subpath;
ConnectApi(this.subpath);
/// Override to `false` for endpoints that must NOT receive a bearer token
/// (currently only login itself, to avoid an infinite refresh loop).
bool get requiresAuth => true;
ConnectHttpMethod get method => ConnectHttpMethod.get;
Map<String, String>? get queryParameters => null;
/// Returns the body to send for POST requests. Should be JSON-encodable.
Object? get body => null;
T assemble(String raw);
Future<T> run() async {
final response = await _runOnce(forceTokenRefresh: false);
if (response.statusCode == 401 && requiresAuth) {
// Single transparent retry after a forced refresh, then bail.
await ConnectAuthStore.instance.invalidate();
final retry = await _runOnce(forceTokenRefresh: true);
if (retry.statusCode == 401) {
throw ConnectException.authFailed(
technicalDetails:
'connect $subpath HTTP 401 after token refresh: ${_safeBody(retry)}',
);
}
return _handleResponse(retry);
}
return _handleResponse(response);
}
Future<http.Response> _runOnce({required bool forceTokenRefresh}) async {
final uri = ConnectEndpoint.resolve(subpath).replace(
queryParameters: _normaliseQuery(queryParameters),
);
final headers = <String, String>{
if (method == ConnectHttpMethod.post) 'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (requiresAuth) {
final token = await ConnectAuthStore.instance.getToken(
forceRefresh: forceTokenRefresh,
);
headers['Authorization'] = 'Bearer $token';
}
try {
switch (method) {
case ConnectHttpMethod.get:
return await http.get(uri, headers: headers);
case ConnectHttpMethod.post:
final payload = body;
return await http.post(
uri,
headers: headers,
body: payload == null ? null : jsonEncode(payload),
);
}
} on SocketException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on TimeoutException catch (e) {
throw NetworkException.timeout(
technicalDetails: 'connect $subpath: $e',
);
} on http.ClientException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on HandshakeException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath TLS: ${e.message}',
);
}
}
T _handleResponse(http.Response response) {
final status = response.statusCode;
final bodyText = _safeBody(response);
if (status == 503) {
final retryAfter = _parseRetryAfter(bodyText);
throw RmvRateLimitedException(
retryAfter: retryAfter,
technicalDetails: 'connect $subpath HTTP 503: $bodyText',
);
}
if (status == 502) {
final code = _parseUpstreamErrorCode(bodyText);
throw RmvUpstreamException(
errorCode: code,
technicalDetails: 'connect $subpath HTTP 502: $bodyText',
);
}
if (status > 299) {
throw ServerException(
statusCode: status,
technicalDetails: 'connect $subpath HTTP $status: $bodyText',
);
}
try {
return assemble(bodyText);
} catch (e, st) {
final preview = bodyText.length > 1024
? '${bodyText.substring(0, 1024)}'
: bodyText;
log(
'connect $subpath assemble failed: $e\nbody: $preview',
stackTrace: st,
);
throw ParseException(
technicalDetails: 'connect $subpath assemble: $e',
);
}
}
String _safeBody(http.Response response) {
try {
return utf8.decode(response.bodyBytes);
} catch (_) {
return response.body;
}
}
/// Body format from `RmvController.wrap`: `upstream_rate_limited|retryAfter=60`.
Duration _parseRetryAfter(String body) {
final match = RegExp(r'retryAfter=(\d+)').firstMatch(body);
final seconds = match == null ? 60 : int.tryParse(match.group(1)!) ?? 60;
return Duration(seconds: seconds);
}
/// Body format: `upstream_error|H390` — the segment after the pipe is the
/// RMV/HaFAS error code.
String? _parseUpstreamErrorCode(String body) {
final idx = body.indexOf('|');
if (idx < 0 || idx >= body.length - 1) return null;
return body.substring(idx + 1).trim();
}
Map<String, String>? _normaliseQuery(Map<String, String>? raw) {
if (raw == null) return null;
final cleaned = <String, String>{};
raw.forEach((key, value) {
if (value.isNotEmpty) cleaned[key] = value;
});
return cleaned.isEmpty ? null : cleaned;
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../model/account_data.dart';
import 'auth/login/login.dart';
import 'auth/login/login_request.dart';
import 'auth/login/login_response.dart';
import 'errors/connect_exception.dart';
/// Holds the Bearer token issued by `POST /auth/login` so that subsequent
/// RMV calls can attach it without prompting the user. Uses the LDAP
/// credentials already kept in [AccountData], so this is transparent to the
/// user — no extra login UI.
class ConnectAuthStore {
static const _tokenKey = 'connect_bearer_token';
static const _expiresAtKey = 'connect_token_expires_at';
static const _tokenName = 'MarianumMobile App';
static const _expiryGuard = Duration(minutes: 1);
static final ConnectAuthStore _instance = ConnectAuthStore._();
factory ConnectAuthStore() => _instance;
static ConnectAuthStore get instance => _instance;
ConnectAuthStore._();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
String? _token;
DateTime? _expiresAt;
bool _hydrated = false;
Future<String>? _inflightLogin;
Future<void> _hydrate() async {
if (_hydrated) return;
_token = await _storage.read(key: _tokenKey);
final rawExp = await _storage.read(key: _expiresAtKey);
_expiresAt = rawExp == null ? null : DateTime.tryParse(rawExp);
_hydrated = true;
}
bool _isUsable() {
if (_token == null || _token!.isEmpty) return false;
final exp = _expiresAt;
if (exp == null) return true;
return DateTime.now().add(_expiryGuard).isBefore(exp);
}
/// Returns a usable bearer token, logging in if necessary. Concurrent
/// callers share the same in-flight login future so a single 401 doesn't
/// trigger N parallel logins.
Future<String> getToken({bool forceRefresh = false}) async {
await _hydrate();
if (!forceRefresh && _isUsable()) return _token!;
return _inflightLogin ??= _login().whenComplete(() {
_inflightLogin = null;
});
}
Future<String> _login() async {
if (!AccountData().isPopulated()) {
throw ConnectException.notAuthenticated();
}
final username = AccountData().getUsername();
final password = AccountData().getPassword();
final LoginResponse response;
try {
response = await Login(
LoginRequest(
username: username,
password: password,
tokenName: _tokenName,
),
).run();
} on ConnectException {
rethrow;
} catch (e, st) {
log('connect login threw: $e', stackTrace: st);
throw ConnectException(
userMessage:
'Anmeldung am Connect-Server fehlgeschlagen. Bitte später erneut versuchen.',
technicalDetails: 'connect login failed: $e',
);
}
_token = response.token;
_expiresAt = response.expiresAt;
await _storage.write(key: _tokenKey, value: response.token);
if (response.expiresAt != null) {
await _storage.write(
key: _expiresAtKey,
value: response.expiresAt!.toIso8601String(),
);
} else {
await _storage.delete(key: _expiresAtKey);
}
return response.token;
}
Future<void> invalidate() async {
_token = null;
_expiresAt = null;
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _expiresAtKey);
}
/// Same as [invalidate] — separate method to make logout call-sites read
/// clearly.
Future<void> clear() => invalidate();
}
+12
View File
@@ -0,0 +1,12 @@
/// Base URL for the MarianumConnect backend. Hardcoded against the test
/// instance for now; once the production URL is finalised this should move
/// into `EndpointData` alongside webuntis/nextcloud.
class ConnectEndpoint {
ConnectEndpoint._();
static const String _baseUrl = 'http://muelleel.ddns.net:8080';
static const String _apiPrefix = '/api/mobile/v1';
static Uri resolve(String subpath) =>
Uri.parse('$_baseUrl$_apiPrefix/${subpath.startsWith('/') ? subpath.substring(1) : subpath}');
}
@@ -0,0 +1,25 @@
import '../../errors/app_exception.dart';
class ConnectException extends AppException {
const ConnectException({
super.userMessage =
'Verbindung zum Marianum-Connect-Server fehlgeschlagen.',
super.technicalDetails,
super.allowRetry,
});
factory ConnectException.authFailed({String? technicalDetails}) =>
ConnectException(
userMessage:
'Anmeldung am Connect-Server fehlgeschlagen. Bitte prüfe deine Anmeldedaten.',
technicalDetails: technicalDetails,
allowRetry: false,
);
factory ConnectException.notAuthenticated() => const ConnectException(
userMessage:
'Für diese Funktion ist eine Anmeldung am Connect-Server nötig.',
technicalDetails: 'AccountData missing while trying to log in to connect',
allowRetry: false,
);
}
@@ -0,0 +1,12 @@
import '../../errors/app_exception.dart';
class RmvRateLimitedException extends AppException {
final Duration retryAfter;
RmvRateLimitedException({required this.retryAfter, super.technicalDetails})
: super(
userMessage:
'Die Fahrplanauskunft ist gerade überlastet. Bitte in ${retryAfter.inSeconds} Sekunden erneut versuchen.',
allowRetry: true,
);
}
@@ -0,0 +1,28 @@
import '../../errors/app_exception.dart';
class RmvUpstreamException extends AppException {
final String? errorCode;
RmvUpstreamException({required this.errorCode, super.technicalDetails})
: super(userMessage: _mapMessage(errorCode), allowRetry: true);
static String _mapMessage(String? code) {
switch (code) {
case 'H390':
return 'Keine Verbindung gefunden.';
case 'H891':
return 'Eine der angegebenen Stationen ist ungültig.';
case 'H895':
return 'Start- und Zielhaltestelle sind identisch.';
case 'H900':
case 'H892':
return 'Die Fahrplanauskunft ist gerade nicht verfügbar.';
case 'H910':
return 'Die angegebene Zeit liegt außerhalb des Fahrplans.';
case null:
return 'Die Fahrplanauskunft konnte keine Antwort liefern.';
default:
return 'Die Fahrplanauskunft hat einen Fehler gemeldet ($code).';
}
}
}
+36
View File
@@ -0,0 +1,36 @@
/// ISO-8601 duration (`PT1H30M5S`) ↔ Dart `Duration`. Backend serialises
/// `java.time.Duration` in this format; Dart has no builtin parser.
class IsoDuration {
IsoDuration._();
static final RegExp _pattern = RegExp(
r'^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$',
);
static Duration? fromJson(String? iso) {
if (iso == null || iso.isEmpty) return null;
final match = _pattern.firstMatch(iso);
if (match == null) return null;
final hours = int.parse(match.group(1) ?? '0');
final minutes = int.parse(match.group(2) ?? '0');
final secondsRaw = match.group(3) ?? '0';
final secondsValue = double.parse(secondsRaw);
return Duration(
hours: hours,
minutes: minutes,
milliseconds: (secondsValue * 1000).round(),
);
}
static String? toJson(Duration? d) {
if (d == null) return null;
final hours = d.inHours;
final minutes = d.inMinutes.remainder(60);
final seconds = d.inSeconds.remainder(60);
final buf = StringBuffer('PT');
if (hours > 0) buf.write('${hours}H');
if (minutes > 0) buf.write('${minutes}M');
if (seconds > 0 || (hours == 0 && minutes == 0)) buf.write('${seconds}S');
return buf.toString();
}
}
@@ -0,0 +1,13 @@
/// Formats a [DateTime] as `2026-05-19T14:30:00` for Java's
/// `LocalDateTime` parser (no timezone, no millis).
String formatLocalDateTime(DateTime dt) {
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}T'
'${two(dt.hour)}:${two(dt.minute)}:${two(dt.second)}';
}
/// Formats a [DateTime] as `2026-05-19` for Java's `LocalDate` parser.
String formatLocalDate(DateTime dt) {
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}';
}
@@ -0,0 +1,32 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetArrivals extends ConnectApi<List<Arrival>> {
final String stopId;
final DateTime? when;
final int durationMinutes;
final int maxJourneys;
GetArrivals({
required this.stopId,
this.when,
this.durationMinutes = 60,
this.maxJourneys = -1,
}) : super('rmv/arrivals');
@override
Map<String, String>? get queryParameters => {
'stopId': stopId,
if (when != null) 'when': formatLocalDateTime(when!),
'duration': durationMinutes.toString(),
'max': maxJourneys.toString(),
};
@override
List<Arrival> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => Arrival.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,32 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetDepartures extends ConnectApi<List<Departure>> {
final String stopId;
final DateTime? when;
final int durationMinutes;
final int maxJourneys;
GetDepartures({
required this.stopId,
this.when,
this.durationMinutes = 60,
this.maxJourneys = -1,
}) : super('rmv/departures');
@override
Map<String, String>? get queryParameters => {
'stopId': stopId,
if (when != null) 'when': formatLocalDateTime(when!),
'duration': durationMinutes.toString(),
'max': maxJourneys.toString(),
};
@override
List<Departure> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => Departure.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,20 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetDisruptions extends ConnectApi<List<HimMessage>> {
final DateTime? when;
GetDisruptions({this.when}) : super('rmv/disruptions');
@override
Map<String, String>? get queryParameters =>
when == null ? null : {'when': formatLocalDateTime(when!)};
@override
List<HimMessage> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => HimMessage.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,23 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetJourneyDetail extends ConnectApi<JourneyDetail> {
final String journeyRef;
final DateTime? date;
GetJourneyDetail({required this.journeyRef, this.date})
: super('rmv/journey');
@override
Map<String, String>? get queryParameters => {
'ref': journeyRef,
if (date != null) 'date': formatLocalDate(date!),
};
@override
JourneyDetail assemble(String raw) =>
JourneyDetail.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,17 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class MoreTrips extends ConnectApi<TripSearchResult> {
final String ctx;
MoreTrips({required this.ctx}) : super('rmv/trips/more');
@override
Map<String, String>? get queryParameters => {'ctx': ctx};
@override
TripSearchResult assemble(String raw) =>
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class NearbyStops extends ConnectApi<List<StopLocation>> {
final double lat;
final double lon;
final int radiusMeters;
final int max;
NearbyStops({
required this.lat,
required this.lon,
this.radiusMeters = 1000,
this.max = 20,
}) : super('rmv/stops/nearby');
@override
Map<String, String>? get queryParameters => {
'lat': lat.toString(),
'lon': lon.toString(),
'radius': radiusMeters.toString(),
'max': max.toString(),
};
@override
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,22 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class SearchStops extends ConnectApi<List<StopLocation>> {
final String query;
final int max;
SearchStops({required this.query, this.max = 10}) : super('rmv/stops');
@override
Map<String, String>? get queryParameters => {
'q': query,
'max': max.toString(),
};
@override
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class SearchTrips extends ConnectApi<TripSearchResult> {
final String fromStopId;
final String toStopId;
final DateTime? when;
final bool searchByArrival;
SearchTrips({
required this.fromStopId,
required this.toStopId,
this.when,
this.searchByArrival = false,
}) : super('rmv/trips');
@override
Map<String, String>? get queryParameters => {
'from': fromStopId,
'to': toStopId,
if (when != null) 'when': formatLocalDateTime(when!),
'searchByArrival': searchByArrival.toString(),
};
@override
TripSearchResult assemble(String raw) =>
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'iso_duration.dart';
part 'rmv_models.freezed.dart';
part 'rmv_models.g.dart';
@freezed
abstract class Product with _$Product {
const factory Product({
String? name,
String? line,
String? displayNumber,
String? category,
String? categoryCode,
String? operator,
}) = _Product;
factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);
}
@freezed
abstract class StopLocation with _$StopLocation {
const factory StopLocation({
required String id,
String? extId,
required String name,
String? description,
double? lat,
double? lon,
int? products,
int? distanceMeters,
}) = _StopLocation;
factory StopLocation.fromJson(Map<String, Object?> json) =>
_$StopLocationFromJson(json);
}
@freezed
abstract class Departure with _$Departure {
const factory Departure({
required String stopId,
String? stopExtId,
required String stopName,
required String name,
required String direction,
String? directionFlag,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
@Default(false) bool cancelled,
@Default(true) bool reachable,
Product? product,
String? journeyRef,
}) = _Departure;
factory Departure.fromJson(Map<String, Object?> json) =>
_$DepartureFromJson(json);
}
@freezed
abstract class Arrival with _$Arrival {
const factory Arrival({
required String stopId,
String? stopExtId,
required String stopName,
required String name,
required String origin,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
@Default(false) bool cancelled,
Product? product,
String? journeyRef,
}) = _Arrival;
factory Arrival.fromJson(Map<String, Object?> json) =>
_$ArrivalFromJson(json);
}
@freezed
abstract class TripEndpoint with _$TripEndpoint {
const factory TripEndpoint({
required String stopId,
String? stopExtId,
required String name,
double? lat,
double? lon,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
String? type,
}) = _TripEndpoint;
factory TripEndpoint.fromJson(Map<String, Object?> json) =>
_$TripEndpointFromJson(json);
}
@freezed
abstract class JourneyStop with _$JourneyStop {
const factory JourneyStop({
required String id,
String? extId,
required String name,
double? lat,
double? lon,
int? routeIdx,
DateTime? scheduledArrival,
DateTime? scheduledDeparture,
DateTime? realArrival,
DateTime? realDeparture,
String? arrTrack,
String? depTrack,
String? realArrTrack,
String? realDepTrack,
@Default(false) bool cancelled,
@Default(false) bool cancelledArrival,
@Default(false) bool cancelledDeparture,
}) = _JourneyStop;
factory JourneyStop.fromJson(Map<String, Object?> json) =>
_$JourneyStopFromJson(json);
}
enum LegType {
@JsonValue('JOURNEY')
journey,
@JsonValue('WALK')
walk,
@JsonValue('TRANSFER')
transfer,
@JsonValue('BIKE')
bike,
@JsonValue('CAR')
car,
@JsonValue('PARK_RIDE')
parkRide,
@JsonValue('TAXI')
taxi,
@JsonValue('CHECK_IN')
checkIn,
@JsonValue('CHECK_OUT')
checkOut,
@JsonValue('DUMMY')
dummy,
@JsonValue('UNKNOWN')
unknown,
}
@freezed
abstract class Leg with _$Leg {
const factory Leg({
required String id,
required int idx,
@Default(LegType.unknown) LegType type,
String? name,
String? category,
String? number,
String? direction,
required TripEndpoint origin,
required TripEndpoint destination,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? duration,
@Default(false) bool cancelled,
@Default(false) bool partCancelled,
@Default(true) bool reachable,
Product? product,
String? journeyRef,
@Default(<JourneyStop>[]) List<JourneyStop> stops,
}) = _Leg;
factory Leg.fromJson(Map<String, Object?> json) => _$LegFromJson(json);
}
@freezed
abstract class Trip with _$Trip {
const factory Trip({
String? tripId,
String? ctxRecon,
String? checksum,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? duration,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? realDuration,
int? transferCount,
@Default(<Leg>[]) List<Leg> legs,
}) = _Trip;
factory Trip.fromJson(Map<String, Object?> json) => _$TripFromJson(json);
}
@freezed
abstract class TripSearchResult with _$TripSearchResult {
const factory TripSearchResult({
@Default(<Trip>[]) List<Trip> trips,
String? scrollContextLater,
String? scrollContextEarlier,
}) = _TripSearchResult;
factory TripSearchResult.fromJson(Map<String, Object?> json) =>
_$TripSearchResultFromJson(json);
}
@freezed
abstract class JourneyDetail with _$JourneyDetail {
const factory JourneyDetail({
String? journeyId,
Product? product,
String? direction,
@Default(<JourneyStop>[]) List<JourneyStop> stops,
}) = _JourneyDetail;
factory JourneyDetail.fromJson(Map<String, Object?> json) =>
_$JourneyDetailFromJson(json);
}
@freezed
abstract class HimMessage with _$HimMessage {
const factory HimMessage({
required String id,
String? externalId,
String? head,
String? lead,
String? text,
String? category,
String? company,
int? priority,
int? products,
DateTime? startValidity,
DateTime? endValidity,
DateTime? modified,
}) = _HimMessage;
factory HimMessage.fromJson(Map<String, Object?> json) =>
_$HimMessageFromJson(json);
}
File diff suppressed because it is too large Load Diff
+368
View File
@@ -0,0 +1,368 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'rmv_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Product _$ProductFromJson(Map<String, dynamic> json) => _Product(
name: json['name'] as String?,
line: json['line'] as String?,
displayNumber: json['displayNumber'] as String?,
category: json['category'] as String?,
categoryCode: json['categoryCode'] as String?,
operator: json['operator'] as String?,
);
Map<String, dynamic> _$ProductToJson(_Product instance) => <String, dynamic>{
'name': instance.name,
'line': instance.line,
'displayNumber': instance.displayNumber,
'category': instance.category,
'categoryCode': instance.categoryCode,
'operator': instance.operator,
};
_StopLocation _$StopLocationFromJson(Map<String, dynamic> json) =>
_StopLocation(
id: json['id'] as String,
extId: json['extId'] as String?,
name: json['name'] as String,
description: json['description'] as String?,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
products: (json['products'] as num?)?.toInt(),
distanceMeters: (json['distanceMeters'] as num?)?.toInt(),
);
Map<String, dynamic> _$StopLocationToJson(_StopLocation instance) =>
<String, dynamic>{
'id': instance.id,
'extId': instance.extId,
'name': instance.name,
'description': instance.description,
'lat': instance.lat,
'lon': instance.lon,
'products': instance.products,
'distanceMeters': instance.distanceMeters,
};
_Departure _$DepartureFromJson(Map<String, dynamic> json) => _Departure(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
stopName: json['stopName'] as String,
name: json['name'] as String,
direction: json['direction'] as String,
directionFlag: json['directionFlag'] as String?,
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
reachable: json['reachable'] as bool? ?? true,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
);
Map<String, dynamic> _$DepartureToJson(_Departure instance) =>
<String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'stopName': instance.stopName,
'name': instance.name,
'direction': instance.direction,
'directionFlag': instance.directionFlag,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'cancelled': instance.cancelled,
'reachable': instance.reachable,
'product': instance.product,
'journeyRef': instance.journeyRef,
};
_Arrival _$ArrivalFromJson(Map<String, dynamic> json) => _Arrival(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
stopName: json['stopName'] as String,
name: json['name'] as String,
origin: json['origin'] as String,
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
);
Map<String, dynamic> _$ArrivalToJson(_Arrival instance) => <String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'stopName': instance.stopName,
'name': instance.name,
'origin': instance.origin,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'cancelled': instance.cancelled,
'product': instance.product,
'journeyRef': instance.journeyRef,
};
_TripEndpoint _$TripEndpointFromJson(Map<String, dynamic> json) =>
_TripEndpoint(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
name: json['name'] as String,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
type: json['type'] as String?,
);
Map<String, dynamic> _$TripEndpointToJson(_TripEndpoint instance) =>
<String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'name': instance.name,
'lat': instance.lat,
'lon': instance.lon,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'type': instance.type,
};
_JourneyStop _$JourneyStopFromJson(Map<String, dynamic> json) => _JourneyStop(
id: json['id'] as String,
extId: json['extId'] as String?,
name: json['name'] as String,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
routeIdx: (json['routeIdx'] as num?)?.toInt(),
scheduledArrival: json['scheduledArrival'] == null
? null
: DateTime.parse(json['scheduledArrival'] as String),
scheduledDeparture: json['scheduledDeparture'] == null
? null
: DateTime.parse(json['scheduledDeparture'] as String),
realArrival: json['realArrival'] == null
? null
: DateTime.parse(json['realArrival'] as String),
realDeparture: json['realDeparture'] == null
? null
: DateTime.parse(json['realDeparture'] as String),
arrTrack: json['arrTrack'] as String?,
depTrack: json['depTrack'] as String?,
realArrTrack: json['realArrTrack'] as String?,
realDepTrack: json['realDepTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
cancelledArrival: json['cancelledArrival'] as bool? ?? false,
cancelledDeparture: json['cancelledDeparture'] as bool? ?? false,
);
Map<String, dynamic> _$JourneyStopToJson(_JourneyStop instance) =>
<String, dynamic>{
'id': instance.id,
'extId': instance.extId,
'name': instance.name,
'lat': instance.lat,
'lon': instance.lon,
'routeIdx': instance.routeIdx,
'scheduledArrival': instance.scheduledArrival?.toIso8601String(),
'scheduledDeparture': instance.scheduledDeparture?.toIso8601String(),
'realArrival': instance.realArrival?.toIso8601String(),
'realDeparture': instance.realDeparture?.toIso8601String(),
'arrTrack': instance.arrTrack,
'depTrack': instance.depTrack,
'realArrTrack': instance.realArrTrack,
'realDepTrack': instance.realDepTrack,
'cancelled': instance.cancelled,
'cancelledArrival': instance.cancelledArrival,
'cancelledDeparture': instance.cancelledDeparture,
};
_Leg _$LegFromJson(Map<String, dynamic> json) => _Leg(
id: json['id'] as String,
idx: (json['idx'] as num).toInt(),
type: $enumDecodeNullable(_$LegTypeEnumMap, json['type']) ?? LegType.unknown,
name: json['name'] as String?,
category: json['category'] as String?,
number: json['number'] as String?,
direction: json['direction'] as String?,
origin: TripEndpoint.fromJson(json['origin'] as Map<String, dynamic>),
destination: TripEndpoint.fromJson(
json['destination'] as Map<String, dynamic>,
),
duration: IsoDuration.fromJson(json['duration'] as String?),
cancelled: json['cancelled'] as bool? ?? false,
partCancelled: json['partCancelled'] as bool? ?? false,
reachable: json['reachable'] as bool? ?? true,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
stops:
(json['stops'] as List<dynamic>?)
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
.toList() ??
const <JourneyStop>[],
);
Map<String, dynamic> _$LegToJson(_Leg instance) => <String, dynamic>{
'id': instance.id,
'idx': instance.idx,
'type': _$LegTypeEnumMap[instance.type]!,
'name': instance.name,
'category': instance.category,
'number': instance.number,
'direction': instance.direction,
'origin': instance.origin,
'destination': instance.destination,
'duration': IsoDuration.toJson(instance.duration),
'cancelled': instance.cancelled,
'partCancelled': instance.partCancelled,
'reachable': instance.reachable,
'product': instance.product,
'journeyRef': instance.journeyRef,
'stops': instance.stops,
};
const _$LegTypeEnumMap = {
LegType.journey: 'JOURNEY',
LegType.walk: 'WALK',
LegType.transfer: 'TRANSFER',
LegType.bike: 'BIKE',
LegType.car: 'CAR',
LegType.parkRide: 'PARK_RIDE',
LegType.taxi: 'TAXI',
LegType.checkIn: 'CHECK_IN',
LegType.checkOut: 'CHECK_OUT',
LegType.dummy: 'DUMMY',
LegType.unknown: 'UNKNOWN',
};
_Trip _$TripFromJson(Map<String, dynamic> json) => _Trip(
tripId: json['tripId'] as String?,
ctxRecon: json['ctxRecon'] as String?,
checksum: json['checksum'] as String?,
duration: IsoDuration.fromJson(json['duration'] as String?),
realDuration: IsoDuration.fromJson(json['realDuration'] as String?),
transferCount: (json['transferCount'] as num?)?.toInt(),
legs:
(json['legs'] as List<dynamic>?)
?.map((e) => Leg.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Leg>[],
);
Map<String, dynamic> _$TripToJson(_Trip instance) => <String, dynamic>{
'tripId': instance.tripId,
'ctxRecon': instance.ctxRecon,
'checksum': instance.checksum,
'duration': IsoDuration.toJson(instance.duration),
'realDuration': IsoDuration.toJson(instance.realDuration),
'transferCount': instance.transferCount,
'legs': instance.legs,
};
_TripSearchResult _$TripSearchResultFromJson(Map<String, dynamic> json) =>
_TripSearchResult(
trips:
(json['trips'] as List<dynamic>?)
?.map((e) => Trip.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Trip>[],
scrollContextLater: json['scrollContextLater'] as String?,
scrollContextEarlier: json['scrollContextEarlier'] as String?,
);
Map<String, dynamic> _$TripSearchResultToJson(_TripSearchResult instance) =>
<String, dynamic>{
'trips': instance.trips,
'scrollContextLater': instance.scrollContextLater,
'scrollContextEarlier': instance.scrollContextEarlier,
};
_JourneyDetail _$JourneyDetailFromJson(Map<String, dynamic> json) =>
_JourneyDetail(
journeyId: json['journeyId'] as String?,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
direction: json['direction'] as String?,
stops:
(json['stops'] as List<dynamic>?)
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
.toList() ??
const <JourneyStop>[],
);
Map<String, dynamic> _$JourneyDetailToJson(_JourneyDetail instance) =>
<String, dynamic>{
'journeyId': instance.journeyId,
'product': instance.product,
'direction': instance.direction,
'stops': instance.stops,
};
_HimMessage _$HimMessageFromJson(Map<String, dynamic> json) => _HimMessage(
id: json['id'] as String,
externalId: json['externalId'] as String?,
head: json['head'] as String?,
lead: json['lead'] as String?,
text: json['text'] as String?,
category: json['category'] as String?,
company: json['company'] as String?,
priority: (json['priority'] as num?)?.toInt(),
products: (json['products'] as num?)?.toInt(),
startValidity: json['startValidity'] == null
? null
: DateTime.parse(json['startValidity'] as String),
endValidity: json['endValidity'] == null
? null
: DateTime.parse(json['endValidity'] as String),
modified: json['modified'] == null
? null
: DateTime.parse(json['modified'] as String),
);
Map<String, dynamic> _$HimMessageToJson(_HimMessage instance) =>
<String, dynamic>{
'id': instance.id,
'externalId': instance.externalId,
'head': instance.head,
'lead': instance.lead,
'text': instance.text,
'category': instance.category,
'company': instance.company,
'priority': instance.priority,
'products': instance.products,
'startValidity': instance.startValidity?.toIso8601String(),
'endValidity': instance.endValidity?.toIso8601String(),
'modified': instance.modified?.toIso8601String(),
};