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
+5 -1
View File
@@ -4,7 +4,8 @@
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"> android:dataExtractionRules="@xml/data_extraction_rules"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -106,4 +107,7 @@
<!-- Workmanager periodic widget refresh needs to reschedule after device <!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. --> reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- RMV "in meiner Nähe"-Suche. Coarse reicht (RMV-Suchradius >= 500 m). -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
</manifest> </manifest>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for the MarianumConnect test instance only. Once the
production URL with HTTPS is live, drop this domain-config entry. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">muelleel.ddns.net</domain>
</domain-config>
</network-security-config>
+15
View File
@@ -43,6 +43,21 @@
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string> <string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Um Haltestellen in deiner Nähe im RMV-Fahrplan zu finden, wird dein aktueller Standort benötigt.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>muelleel.ddns.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
+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(),
};
+2
View File
@@ -17,6 +17,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'api/connect/connect_auth_store.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
import 'app.dart'; import 'app.dart';
@@ -319,6 +320,7 @@ Future<void> _wipeUserState({
chatListBloc.reset(), chatListBloc.reset(),
chatBloc.reset(), chatBloc.reset(),
breakerBloc.reset(), breakerBloc.reset(),
ConnectAuthStore.instance.clear(),
]); ]);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); await prefs.clear();
+57
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../api/connect/rmv/rmv_models.dart';
import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart'; import '../model/account_data.dart';
@@ -18,6 +19,11 @@ import '../view/pages/marianum_message/marianum_message_view.dart';
import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/feedback/feedback_dialog.dart';
import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/rmv/disruptions/disruptions_view.dart';
import '../view/pages/rmv/journey/journey_detail_view.dart';
import '../view/pages/rmv/stations/station_detail_view.dart';
import '../view/pages/rmv/trip_search/trip_detail_view.dart';
import '../view/pages/rmv/trip_search/trip_results_view.dart';
import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart'; import '../view/pages/settings/settings.dart';
import '../view/pages/share_intent/share_chat_picker.dart'; import '../view/pages/share_intent/share_chat_picker.dart';
@@ -106,6 +112,57 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const Roomplan()); pushScreen(context, withNavBar: false, screen: const Roomplan());
} }
static void openRmvDisruptions(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const DisruptionsView());
}
static void openRmvStationDetail(BuildContext context, StopLocation station) {
pushScreen(
context,
withNavBar: false,
screen: StationDetailView(station: station),
);
}
static void openRmvJourneyDetail(
BuildContext context,
String journeyRef, {
DateTime? date,
}) {
pushScreen(
context,
withNavBar: false,
screen: JourneyDetailView(journeyRef: journeyRef, date: date),
);
}
static void openRmvTripResults(
BuildContext context, {
required StopLocation from,
required StopLocation to,
DateTime? when,
bool byArrival = false,
}) {
pushScreen(
context,
withNavBar: false,
screen: TripResultsView(
from: from,
to: to,
when: when,
byArrival: byArrival,
),
);
}
static void openRmvTripDetail(BuildContext context, Trip trip) {
pushScreen(
context,
withNavBar: false,
screen: TripDetailView(trip: trip),
);
}
static void openShareTarget(BuildContext context, PendingShare share) { static void openShareTarget(BuildContext context, PendingShare share) {
pushScreen( pushScreen(
context, context,
+9
View File
@@ -11,6 +11,7 @@ import '../../../view/pages/holidays/holidays_view.dart';
import '../../../view/pages/marianum_dates/marianum_dates_view.dart'; import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart'; import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart'; import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/rmv/rmv_view.dart';
import '../../../view/pages/talk/chat_list.dart'; import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/timetable/timetable.dart'; import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart'; import '../../../widget/breaker/breaker.dart';
@@ -126,6 +127,13 @@ class AppModule {
breakerArea: BreakerArea.more, breakerArea: BreakerArea.more,
create: MarianumDatesView.new, create: MarianumDatesView.new,
), ),
Modules.rmv: AppModule(
Modules.rmv,
name: 'RMV-Fahrplan',
icon: () => Icon(Icons.directions_bus),
breakerArea: BreakerArea.more,
create: RmvView.new,
),
}; };
if (!showFiltered) { if (!showFiltered) {
@@ -232,4 +240,5 @@ enum Modules {
gradeAveragesCalculator, gradeAveragesCalculator,
holidays, holidays,
marianumDates, marianumDates,
rmv,
} }
@@ -0,0 +1,29 @@
import '../../../../../api/connect/rmv/rmv_models.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/rmv_repository.dart';
import 'rmv_event.dart';
import 'rmv_state.dart';
class RmvBloc
extends LoadableHydratedBloc<RmvEvent, RmvState, RmvRepository> {
List<HimMessage> getDisruptions() => innerState?.disruptions ?? const [];
@override
RmvState fromNothing() => const RmvState();
@override
RmvState fromStorage(Map<String, dynamic> json) => RmvState.fromJson(json);
@override
Map<String, dynamic>? toStorage(RmvState state) => state.toJson();
@override
Future<void> gatherData() async {
final disruptions = await repo.disruptions();
add(DataGathered((state) => state.copyWith(disruptions: disruptions)));
}
@override
RmvRepository repository() => RmvRepository();
}
@@ -0,0 +1,4 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'rmv_state.dart';
abstract class RmvEvent extends LoadableHydratedBlocEvent<RmvState> {}
@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/connect/rmv/rmv_models.dart';
part 'rmv_state.freezed.dart';
part 'rmv_state.g.dart';
@freezed
abstract class RmvState with _$RmvState {
const factory RmvState({@Default(<HimMessage>[]) List<HimMessage> disruptions}) =
_RmvState;
factory RmvState.fromJson(Map<String, Object?> json) =>
_$RmvStateFromJson(json);
}
@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'rmv_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RmvState {
List<HimMessage> get disruptions;
/// Create a copy of RmvState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RmvStateCopyWith<RmvState> get copyWith => _$RmvStateCopyWithImpl<RmvState>(this as RmvState, _$identity);
/// Serializes this RmvState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RmvState&&const DeepCollectionEquality().equals(other.disruptions, disruptions));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(disruptions));
@override
String toString() {
return 'RmvState(disruptions: $disruptions)';
}
}
/// @nodoc
abstract mixin class $RmvStateCopyWith<$Res> {
factory $RmvStateCopyWith(RmvState value, $Res Function(RmvState) _then) = _$RmvStateCopyWithImpl;
@useResult
$Res call({
List<HimMessage> disruptions
});
}
/// @nodoc
class _$RmvStateCopyWithImpl<$Res>
implements $RmvStateCopyWith<$Res> {
_$RmvStateCopyWithImpl(this._self, this._then);
final RmvState _self;
final $Res Function(RmvState) _then;
/// Create a copy of RmvState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? disruptions = null,}) {
return _then(_self.copyWith(
disruptions: null == disruptions ? _self.disruptions : disruptions // ignore: cast_nullable_to_non_nullable
as List<HimMessage>,
));
}
}
/// Adds pattern-matching-related methods to [RmvState].
extension RmvStatePatterns on RmvState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RmvState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RmvState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RmvState value) $default,){
final _that = this;
switch (_that) {
case _RmvState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RmvState value)? $default,){
final _that = this;
switch (_that) {
case _RmvState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<HimMessage> disruptions)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RmvState() when $default != null:
return $default(_that.disruptions);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<HimMessage> disruptions) $default,) {final _that = this;
switch (_that) {
case _RmvState():
return $default(_that.disruptions);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<HimMessage> disruptions)? $default,) {final _that = this;
switch (_that) {
case _RmvState() when $default != null:
return $default(_that.disruptions);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _RmvState implements RmvState {
const _RmvState({final List<HimMessage> disruptions = const <HimMessage>[]}): _disruptions = disruptions;
factory _RmvState.fromJson(Map<String, dynamic> json) => _$RmvStateFromJson(json);
final List<HimMessage> _disruptions;
@override@JsonKey() List<HimMessage> get disruptions {
if (_disruptions is EqualUnmodifiableListView) return _disruptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_disruptions);
}
/// Create a copy of RmvState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RmvStateCopyWith<_RmvState> get copyWith => __$RmvStateCopyWithImpl<_RmvState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RmvStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RmvState&&const DeepCollectionEquality().equals(other._disruptions, _disruptions));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_disruptions));
@override
String toString() {
return 'RmvState(disruptions: $disruptions)';
}
}
/// @nodoc
abstract mixin class _$RmvStateCopyWith<$Res> implements $RmvStateCopyWith<$Res> {
factory _$RmvStateCopyWith(_RmvState value, $Res Function(_RmvState) _then) = __$RmvStateCopyWithImpl;
@override @useResult
$Res call({
List<HimMessage> disruptions
});
}
/// @nodoc
class __$RmvStateCopyWithImpl<$Res>
implements _$RmvStateCopyWith<$Res> {
__$RmvStateCopyWithImpl(this._self, this._then);
final _RmvState _self;
final $Res Function(_RmvState) _then;
/// Create a copy of RmvState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? disruptions = null,}) {
return _then(_RmvState(
disruptions: null == disruptions ? _self._disruptions : disruptions // ignore: cast_nullable_to_non_nullable
as List<HimMessage>,
));
}
}
// dart format on
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'rmv_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_RmvState _$RmvStateFromJson(Map<String, dynamic> json) => _RmvState(
disruptions:
(json['disruptions'] as List<dynamic>?)
?.map((e) => HimMessage.fromJson(e as Map<String, dynamic>))
.toList() ??
const <HimMessage>[],
);
Map<String, dynamic> _$RmvStateToJson(_RmvState instance) => <String, dynamic>{
'disruptions': instance.disruptions,
};
@@ -0,0 +1,73 @@
import '../../../../../api/connect/rmv/queries/get_arrivals.dart';
import '../../../../../api/connect/rmv/queries/get_departures.dart';
import '../../../../../api/connect/rmv/queries/get_disruptions.dart';
import '../../../../../api/connect/rmv/queries/get_journey_detail.dart';
import '../../../../../api/connect/rmv/queries/more_trips.dart';
import '../../../../../api/connect/rmv/queries/nearby_stops.dart';
import '../../../../../api/connect/rmv/queries/search_stops.dart';
import '../../../../../api/connect/rmv/queries/search_trips.dart';
import '../../../../../api/connect/rmv/rmv_models.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/rmv_state.dart';
class RmvRepository extends Repository<RmvState> {
Future<List<StopLocation>> searchStops(String query, {int max = 10}) =>
SearchStops(query: query, max: max).run();
Future<List<StopLocation>> nearbyStops({
required double lat,
required double lon,
int radiusMeters = 1000,
int max = 20,
}) => NearbyStops(
lat: lat,
lon: lon,
radiusMeters: radiusMeters,
max: max,
).run();
Future<List<Departure>> departures(
String stopId, {
DateTime? when,
int durationMinutes = 60,
int maxJourneys = -1,
}) => GetDepartures(
stopId: stopId,
when: when,
durationMinutes: durationMinutes,
maxJourneys: maxJourneys,
).run();
Future<List<Arrival>> arrivals(
String stopId, {
DateTime? when,
int durationMinutes = 60,
int maxJourneys = -1,
}) => GetArrivals(
stopId: stopId,
when: when,
durationMinutes: durationMinutes,
maxJourneys: maxJourneys,
).run();
Future<TripSearchResult> searchTrips({
required String fromStopId,
required String toStopId,
DateTime? when,
bool searchByArrival = false,
}) => SearchTrips(
fromStopId: fromStopId,
toStopId: toStopId,
when: when,
searchByArrival: searchByArrival,
).run();
Future<TripSearchResult> moreTrips(String ctx) =>
MoreTrips(ctx: ctx).run();
Future<JourneyDetail> journeyDetail(String ref, {DateTime? date}) =>
GetJourneyDetail(journeyRef: ref, date: date).run();
Future<List<HimMessage>> disruptions({DateTime? when}) =>
GetDisruptions(when: when).run();
}
+1
View File
@@ -38,4 +38,5 @@ const _$ModulesEnumMap = {
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
Modules.holidays: 'holidays', Modules.holidays: 'holidays',
Modules.marianumDates: 'marianumDates', Modules.marianumDates: 'marianumDates',
Modules.rmv: 'rmv',
}; };
+41
View File
@@ -0,0 +1,41 @@
import 'package:json_annotation/json_annotation.dart';
import '../api/connect/rmv/rmv_models.dart';
part 'rmv_settings.g.dart';
@JsonSerializable(explicitToJson: true)
class RmvSettings {
List<StopLocation> favoriteStations;
List<StopLocation> recentStations;
List<RecentTripQuery> recentTripQueries;
static const int maxRecents = 10;
RmvSettings({
this.favoriteStations = const [],
this.recentStations = const [],
this.recentTripQueries = const [],
});
factory RmvSettings.fromJson(Map<String, dynamic> json) =>
_$RmvSettingsFromJson(json);
Map<String, dynamic> toJson() => _$RmvSettingsToJson(this);
}
@JsonSerializable(explicitToJson: true)
class RecentTripQuery {
final StopLocation from;
final StopLocation to;
final int timestampMs;
RecentTripQuery({
required this.from,
required this.to,
required this.timestampMs,
});
factory RecentTripQuery.fromJson(Map<String, dynamic> json) =>
_$RecentTripQueryFromJson(json);
Map<String, dynamic> toJson() => _$RecentTripQueryToJson(this);
}
+49
View File
@@ -0,0 +1,49 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'rmv_settings.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RmvSettings _$RmvSettingsFromJson(Map<String, dynamic> json) => RmvSettings(
favoriteStations:
(json['favoriteStations'] as List<dynamic>?)
?.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
recentStations:
(json['recentStations'] as List<dynamic>?)
?.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
recentTripQueries:
(json['recentTripQueries'] as List<dynamic>?)
?.map((e) => RecentTripQuery.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$RmvSettingsToJson(
RmvSettings instance,
) => <String, dynamic>{
'favoriteStations': instance.favoriteStations.map((e) => e.toJson()).toList(),
'recentStations': instance.recentStations.map((e) => e.toJson()).toList(),
'recentTripQueries': instance.recentTripQueries
.map((e) => e.toJson())
.toList(),
};
RecentTripQuery _$RecentTripQueryFromJson(Map<String, dynamic> json) =>
RecentTripQuery(
from: StopLocation.fromJson(json['from'] as Map<String, dynamic>),
to: StopLocation.fromJson(json['to'] as Map<String, dynamic>),
timestampMs: (json['timestampMs'] as num).toInt(),
);
Map<String, dynamic> _$RecentTripQueryToJson(RecentTripQuery instance) =>
<String, dynamic>{
'from': instance.from.toJson(),
'to': instance.to.toJson(),
'timestampMs': instance.timestampMs,
};
+3
View File
@@ -7,6 +7,7 @@ import 'file_view_settings.dart';
import 'holidays_settings.dart'; import 'holidays_settings.dart';
import 'modules_settings.dart'; import 'modules_settings.dart';
import 'notification_settings.dart'; import 'notification_settings.dart';
import 'rmv_settings.dart';
import 'talk_settings.dart'; import 'talk_settings.dart';
import 'timetable_settings.dart'; import 'timetable_settings.dart';
@@ -23,6 +24,7 @@ class Settings {
TalkSettings talkSettings; TalkSettings talkSettings;
FileSettings fileSettings; FileSettings fileSettings;
HolidaysSettings holidaysSettings; HolidaysSettings holidaysSettings;
RmvSettings rmvSettings;
FileViewSettings fileViewSettings; FileViewSettings fileViewSettings;
NotificationSettings notificationSettings; NotificationSettings notificationSettings;
DevToolsSettings devToolsSettings; DevToolsSettings devToolsSettings;
@@ -35,6 +37,7 @@ class Settings {
required this.talkSettings, required this.talkSettings,
required this.fileSettings, required this.fileSettings,
required this.holidaysSettings, required this.holidaysSettings,
required this.rmvSettings,
required this.fileViewSettings, required this.fileViewSettings,
required this.notificationSettings, required this.notificationSettings,
required this.devToolsSettings, required this.devToolsSettings,
+4
View File
@@ -24,6 +24,9 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings(
holidaysSettings: HolidaysSettings.fromJson( holidaysSettings: HolidaysSettings.fromJson(
json['holidaysSettings'] as Map<String, dynamic>, json['holidaysSettings'] as Map<String, dynamic>,
), ),
rmvSettings: RmvSettings.fromJson(
json['rmvSettings'] as Map<String, dynamic>,
),
fileViewSettings: FileViewSettings.fromJson( fileViewSettings: FileViewSettings.fromJson(
json['fileViewSettings'] as Map<String, dynamic>, json['fileViewSettings'] as Map<String, dynamic>,
), ),
@@ -43,6 +46,7 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
'talkSettings': instance.talkSettings.toJson(), 'talkSettings': instance.talkSettings.toJson(),
'fileSettings': instance.fileSettings.toJson(), 'fileSettings': instance.fileSettings.toJson(),
'holidaysSettings': instance.holidaysSettings.toJson(), 'holidaysSettings': instance.holidaysSettings.toJson(),
'rmvSettings': instance.rmvSettings.toJson(),
'fileViewSettings': instance.fileViewSettings.toJson(), 'fileViewSettings': instance.fileViewSettings.toJson(),
'notificationSettings': instance.notificationSettings.toJson(), 'notificationSettings': instance.notificationSettings.toJson(),
'devToolsSettings': instance.devToolsSettings.toJson(), 'devToolsSettings': instance.devToolsSettings.toJson(),
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/details_bottom_sheet.dart';
class DisruptionsView extends StatefulWidget {
const DisruptionsView({super.key});
@override
State<DisruptionsView> createState() => _DisruptionsViewState();
}
class _DisruptionsViewState extends State<DisruptionsView> {
final RmvRepository _repo = RmvRepository();
List<HimMessage>? _items;
bool _loading = true;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final r = await _repo.disruptions();
if (!mounted) return;
r.sort((a, b) => (b.priority ?? 0).compareTo(a.priority ?? 0));
setState(() {
_items = r;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Störungsmeldungen')),
body: _body(),
);
Widget _body() {
if (_loading) return const Center(child: AppProgressIndicator.large());
final err = _error;
if (err != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorToUserMessage(err),
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.refresh),
onPressed: _load,
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
final list = _items ?? const <HimMessage>[];
if (list.isEmpty) {
return RefreshIndicator(
onRefresh: _load,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
Padding(
padding: EdgeInsets.symmetric(vertical: 80, horizontal: 24),
child: Center(child: Text('Keine aktiven Meldungen.')),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: list.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => _tile(list[i]),
),
);
}
Widget _tile(HimMessage msg) => ListTile(
leading: Icon(
Icons.warning_amber_outlined,
color: _priorityColor(msg.priority),
),
title: Text(
msg.head ?? msg.lead ?? msg.text ?? 'Meldung',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: msg.lead == null
? null
: Text(msg.lead!, maxLines: 2, overflow: TextOverflow.ellipsis),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showDetails(msg),
);
Color _priorityColor(int? priority) {
if (priority == null) return Colors.orange;
if (priority >= 100) return Colors.red;
if (priority >= 50) return Colors.orange;
return Colors.amber;
}
void _showDetails(HimMessage msg) {
showDetailsBottomSheet(
context,
header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
msg.head ?? 'Störungsmeldung',
style: Theme.of(context).textTheme.titleLarge,
),
),
children: (ctx) => [
if (msg.lead != null)
ListTile(
leading: const Icon(Icons.short_text),
title: Text(msg.lead!),
),
if (msg.text != null)
ListTile(
leading: const Icon(Icons.notes),
title: Text(msg.text!),
),
if (msg.category != null)
ListTile(
leading: const Icon(Icons.category_outlined),
title: Text(msg.category!),
),
if (msg.company != null)
ListTile(
leading: const Icon(Icons.business_outlined),
title: Text(msg.company!),
),
if (msg.startValidity != null || msg.endValidity != null)
ListTile(
leading: const Icon(Icons.event_outlined),
title: Text(_validityRange(msg)),
),
if (msg.modified != null)
ListTile(
leading: const Icon(Icons.update_outlined),
title: Text('Aktualisiert: ${msg.modified!.formatDateTime()}'),
),
],
);
}
String _validityRange(HimMessage msg) {
final start = msg.startValidity?.formatDateTime();
final end = msg.endValidity?.formatDateTime();
if (start != null && end != null) return '$start $end';
return start ?? end ?? '';
}
}
@@ -0,0 +1,77 @@
import '../../../api/connect/rmv/rmv_models.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/rmv_settings.dart';
/// Thin wrapper around the global [SettingsCubit] that keeps the RMV
/// favorites/recents bookkeeping out of every view. All mutations go through
/// here so that the cubit's write/emit cycle and the recent-list trimming
/// stay consistent.
class RmvFavoritesController {
final SettingsCubit _settings;
RmvFavoritesController(this._settings);
RmvSettings get _rmv => _settings.val().rmvSettings;
bool isFavorite(StopLocation stop) =>
_rmv.favoriteStations.any((s) => s.id == stop.id);
void toggleFavorite(StopLocation stop) {
if (isFavorite(stop)) {
removeFavorite(stop);
} else {
addFavorite(stop);
}
}
void addFavorite(StopLocation stop) {
final mutable = _settings.val(write: true).rmvSettings;
if (mutable.favoriteStations.any((s) => s.id == stop.id)) return;
mutable.favoriteStations = [...mutable.favoriteStations, stop];
}
void removeFavorite(StopLocation stop) {
final mutable = _settings.val(write: true).rmvSettings;
mutable.favoriteStations = mutable.favoriteStations
.where((s) => s.id != stop.id)
.toList();
}
void addRecent(StopLocation stop) {
final mutable = _settings.val(write: true).rmvSettings;
final filtered =
mutable.recentStations.where((s) => s.id != stop.id).toList();
filtered.insert(0, stop);
if (filtered.length > RmvSettings.maxRecents) {
filtered.removeRange(RmvSettings.maxRecents, filtered.length);
}
mutable.recentStations = filtered;
}
void clearRecents() {
_settings.val(write: true).rmvSettings.recentStations = const [];
}
void addRecentTrip(StopLocation from, StopLocation to) {
final mutable = _settings.val(write: true).rmvSettings;
final filtered = mutable.recentTripQueries
.where((q) => q.from.id != from.id || q.to.id != to.id)
.toList();
filtered.insert(
0,
RecentTripQuery(
from: from,
to: to,
timestampMs: DateTime.now().millisecondsSinceEpoch,
),
);
if (filtered.length > RmvSettings.maxRecents) {
filtered.removeRange(RmvSettings.maxRecents, filtered.length);
}
mutable.recentTripQueries = filtered;
}
void clearRecentTrips() {
_settings.val(write: true).rmvSettings.recentTripQueries = const [];
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../widgets/product_chip.dart';
import '../widgets/realtime_time.dart';
class JourneyDetailView extends StatefulWidget {
final String journeyRef;
final DateTime? date;
const JourneyDetailView({super.key, required this.journeyRef, this.date});
@override
State<JourneyDetailView> createState() => _JourneyDetailViewState();
}
class _JourneyDetailViewState extends State<JourneyDetailView> {
final RmvRepository _repo = RmvRepository();
JourneyDetail? _detail;
bool _loading = true;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final detail = await _repo.journeyDetail(
widget.journeyRef,
date: widget.date,
);
if (!mounted) return;
setState(() {
_detail = detail;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final detail = _detail;
return Scaffold(
appBar: AppBar(
title: Row(
children: [
ProductChip(product: detail?.product),
const SizedBox(width: 8),
Expanded(
child: Text(
detail?.direction ?? 'Fahrt',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
body: _body(),
);
}
Widget _body() {
if (_loading) {
return const Center(child: AppProgressIndicator.large());
}
final err = _error;
if (err != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
errorToUserMessage(err),
textAlign: TextAlign.center,
),
),
);
}
final stops = _detail?.stops ?? const <JourneyStop>[];
if (stops.isEmpty) {
return const Center(child: Text('Keine Halte verfügbar.'));
}
return RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: stops.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => _stopTile(stops[i], i, stops.length),
),
);
}
Widget _stopTile(JourneyStop stop, int idx, int total) {
final isFirst = idx == 0;
final isLast = idx == total - 1;
final arrival = stop.realArrival ?? stop.scheduledArrival;
final departure = stop.realDeparture ?? stop.scheduledDeparture;
final track = (stop.realDepTrack?.isNotEmpty ?? false)
? stop.realDepTrack
: stop.depTrack;
return ListTile(
leading: SizedBox(
width: 24,
child: Icon(
isFirst
? Icons.trip_origin
: (isLast ? Icons.place : Icons.fiber_manual_record),
size: isFirst || isLast ? 20 : 10,
color: stop.cancelled
? Colors.red
: Theme.of(context).colorScheme.primary,
),
),
title: Text(
stop.name,
style: TextStyle(
decoration: stop.cancelled ? TextDecoration.lineThrough : null,
fontWeight: (isFirst || isLast) ? FontWeight.bold : null,
),
),
subtitle: (track == null || track.isEmpty)
? null
: Text('Gleis $track'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (stop.scheduledArrival != null)
RealtimeTime(
scheduled: stop.scheduledArrival!,
realtime: stop.realArrival,
cancelled: stop.cancelledArrival,
style: Theme.of(context).textTheme.bodyMedium,
),
if (stop.scheduledDeparture != null &&
stop.scheduledDeparture != stop.scheduledArrival)
RealtimeTime(
scheduled: stop.scheduledDeparture!,
realtime: stop.realDeparture,
cancelled: stop.cancelledDeparture,
style: Theme.of(context).textTheme.bodyMedium,
),
if (arrival == null && departure == null) const Text('-'),
],
),
isThreeLine: arrival != null && departure != null && arrival != departure,
);
}
}
/// Allows showing "departure 14:35" as a tooltip in the journey timeline.
String formatStopMoment(DateTime t) => t.formatHm();
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/rmv/bloc/rmv_bloc.dart';
import '../../../state/app/modules/rmv/bloc/rmv_state.dart';
import 'stations/station_overview_tab.dart';
import 'trip_search/trip_search_tab.dart';
class RmvView extends StatelessWidget {
const RmvView({super.key});
@override
Widget build(BuildContext context) =>
BlocModule<RmvBloc, LoadableState<RmvState>>(
create: (context) => RmvBloc(),
autoRebuild: true,
child: (context, bloc, state) {
final disruptions = bloc.getDisruptions();
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('RMV-Fahrplan'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.alt_route), text: 'Verbindung'),
Tab(icon: Icon(Icons.directions_bus), text: 'Stationen'),
],
),
actions: [
Builder(
builder: (ctx) => IconButton(
icon: _disruptionsIcon(disruptions.length),
tooltip: 'Störungsmeldungen',
onPressed: () => AppRoutes.openRmvDisruptions(ctx),
),
),
],
),
body: const TabBarView(
children: [
TripSearchTab(),
StationOverviewTab(),
],
),
),
);
},
);
Widget _disruptionsIcon(int count) {
if (count <= 0) return const Icon(Icons.warning_amber_outlined);
return Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.warning_amber_outlined),
Positioned(
right: -6,
top: -6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: Text(
count > 99 ? '99+' : '$count',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
}
@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/centered_leading.dart';
class NearbyStationsView extends StatefulWidget {
const NearbyStationsView({super.key});
@override
State<NearbyStationsView> createState() => _NearbyStationsViewState();
}
class _NearbyStationsViewState extends State<NearbyStationsView> {
final RmvRepository _repo = RmvRepository();
List<StopLocation>? _stops;
bool _loading = true;
String? _userError;
Object? _apiError;
int _radiusMeters = 1000;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_userError = null;
_apiError = null;
});
final position = await _resolvePosition();
if (!mounted) return;
if (position == null) {
// _userError is set by _resolvePosition
setState(() => _loading = false);
return;
}
try {
final stops = await _repo.nearbyStops(
lat: position.latitude,
lon: position.longitude,
radiusMeters: _radiusMeters,
max: 30,
);
if (!mounted) return;
stops.sort((a, b) =>
(a.distanceMeters ?? 0).compareTo(b.distanceMeters ?? 0));
setState(() {
_stops = stops;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_apiError = e;
_loading = false;
});
}
}
Future<Position?> _resolvePosition() async {
if (!await Geolocator.isLocationServiceEnabled()) {
_userError =
'Bitte aktiviere die Standortdienste in den System-Einstellungen.';
return null;
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever) {
_userError =
'Standortzugriff dauerhaft verweigert. Bitte in den App-Einstellungen aktivieren.';
return null;
}
if (permission == LocationPermission.denied) {
_userError = 'Ohne Standortzugriff können keine Stationen in der Nähe gefunden werden.';
return null;
}
try {
return await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 10),
),
);
} catch (e) {
_userError = 'Standort konnte nicht ermittelt werden: $e';
return null;
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('In meiner Nähe'),
actions: [
PopupMenuButton<int>(
tooltip: 'Suchradius',
icon: const Icon(Icons.tune),
onSelected: (r) {
setState(() => _radiusMeters = r);
_load();
},
itemBuilder: (_) => [500, 1000, 2000, 5000]
.map(
(r) => CheckedPopupMenuItem<int>(
value: r,
checked: r == _radiusMeters,
child: Text(r >= 1000 ? '${r ~/ 1000} km' : '$r m'),
),
)
.toList(),
),
],
),
body: _body(),
);
Widget _body() {
if (_loading) return const Center(child: AppProgressIndicator.large());
if (_userError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_userError!, textAlign: TextAlign.center),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.refresh),
onPressed: _load,
label: const Text('Erneut versuchen'),
),
OutlinedButton.icon(
icon: const Icon(Icons.settings),
onPressed: Geolocator.openAppSettings,
label: const Text('Einstellungen'),
),
],
),
],
),
),
);
}
if (_apiError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorToUserMessage(_apiError),
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.refresh),
onPressed: _load,
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
final list = _stops ?? const <StopLocation>[];
if (list.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Keine Stationen im gewählten Umkreis gefunden.',
textAlign: TextAlign.center,
),
),
);
}
return RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: list.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => _tile(list[i]),
),
);
}
Widget _tile(StopLocation stop) => ListTile(
leading: const CenteredLeading(Icon(Icons.directions_transit)),
title: Text(stop.name),
subtitle: stop.distanceMeters == null
? null
: Text('${stop.distanceMeters} m entfernt'),
onTap: () => AppRoutes.openRmvStationDetail(context, stop),
);
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../favorites_controller.dart';
import '../widgets/departure_arrival_tile.dart';
enum _Direction { departures, arrivals }
class StationDetailView extends StatefulWidget {
final StopLocation station;
const StationDetailView({super.key, required this.station});
@override
State<StationDetailView> createState() => _StationDetailViewState();
}
class _StationDetailViewState extends State<StationDetailView> {
final RmvRepository _repo = RmvRepository();
_Direction _direction = _Direction.departures;
List<Departure>? _departures;
List<Arrival>? _arrivals;
bool _loading = false;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
if (_direction == _Direction.departures) {
final result = await _repo.departures(widget.station.id);
if (!mounted) return;
setState(() {
_departures = result;
_loading = false;
});
} else {
final result = await _repo.arrivals(widget.station.id);
if (!mounted) return;
setState(() {
_arrivals = result;
_loading = false;
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
void _switch(_Direction d) {
if (d == _direction) return;
setState(() {
_direction = d;
_departures = null;
_arrivals = null;
});
_load();
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsCubit>();
final favCtrl = RmvFavoritesController(settings);
final isFav = favCtrl.isFavorite(widget.station);
return Scaffold(
appBar: AppBar(
title: Text(widget.station.name),
actions: [
IconButton(
tooltip: isFav ? 'Favorit entfernen' : 'Als Favorit speichern',
icon: Icon(isFav ? Icons.star : Icons.star_border),
onPressed: () => favCtrl.toggleFavorite(widget.station),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: SegmentedButton<_Direction>(
segments: const [
ButtonSegment(
value: _Direction.departures,
icon: Icon(Icons.north_east),
label: Text('Abfahrten'),
),
ButtonSegment(
value: _Direction.arrivals,
icon: Icon(Icons.south_west),
label: Text('Ankünfte'),
),
],
selected: {_direction},
onSelectionChanged: (s) => _switch(s.first),
),
),
Expanded(child: _body()),
],
),
);
}
Widget _body() {
if (_loading) {
return const Center(child: AppProgressIndicator.large());
}
final err = _error;
if (err != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorToUserMessage(err),
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.refresh),
onPressed: _load,
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (_direction == _Direction.departures) {
final list = _departures ?? const <Departure>[];
if (list.isEmpty) return _emptyState('Keine Abfahrten gefunden.');
return RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: list.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => DepartureArrivalTile.fromDeparture(
list[i],
onTap: () => _openJourney(list[i].journeyRef),
),
),
);
}
final list = _arrivals ?? const <Arrival>[];
if (list.isEmpty) return _emptyState('Keine Ankünfte gefunden.');
return RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: list.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => DepartureArrivalTile.fromArrival(
list[i],
onTap: () => _openJourney(list[i].journeyRef),
),
),
);
}
Widget _emptyState(String text) => RefreshIndicator(
onRefresh: _load,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 80, horizontal: 24),
child: Center(
child: Text(text, style: Theme.of(context).textTheme.bodyLarge),
),
),
],
),
);
void _openJourney(String? ref) {
if (ref == null) return;
AppRoutes.openRmvJourneyDetail(context, ref);
}
}
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/settings.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../favorites_controller.dart';
import '../widgets/station_picker_sheet.dart';
import 'nearby_stations_view.dart';
class StationOverviewTab extends StatelessWidget {
const StationOverviewTab({super.key});
@override
Widget build(BuildContext context) =>
BlocBuilder<SettingsCubit, Settings>(builder: _buildBody);
Widget _buildBody(BuildContext context, Settings settings) {
final rmv = settings.rmvSettings;
final favorites = rmv.favoriteStations;
final recents = rmv.recentStations;
final favCtrl = RmvFavoritesController(context.read<SettingsCubit>());
final children = <Widget>[
_searchBar(context),
_nearbyButton(context),
if (favorites.isEmpty && recents.isEmpty) _emptyState(context),
if (favorites.isNotEmpty) ...[
_sectionHeader(context, 'Favoriten', null),
...favorites.map((s) => _stationTile(context, s, favCtrl, isFavorite: true)),
],
if (recents.isNotEmpty) ...[
_sectionHeader(
context,
'Zuletzt verwendet',
IconButton(
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: 'Alle löschen',
onPressed: () => _confirmClearRecents(context, favCtrl),
),
),
...recents.map((s) => _stationTile(context, s, favCtrl, isFavorite: false)),
],
];
return ListView(children: children);
}
Widget _searchBar(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: FilledButton.tonalIcon(
icon: const Icon(Icons.search),
label: const Text('Station suchen…'),
onPressed: () async {
final picked = await showStationPickerSheet(context);
if (picked != null && context.mounted) {
AppRoutes.openRmvStationDetail(context, picked);
}
},
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
alignment: Alignment.centerLeft,
),
),
);
Widget _nearbyButton(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
icon: const Icon(Icons.my_location),
label: const Text('In meiner Nähe'),
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const NearbyStationsView()),
),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(40),
),
),
);
Widget _sectionHeader(BuildContext context, String title, Widget? trailing) =>
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
),
?trailing,
],
),
);
Widget _emptyState(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 40, 24, 16),
child: Center(
child: Text(
'Noch keine Stationen gespeichert. Suche eine Station, um sie zu öffnen oder als Favorit zu markieren.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
Widget _stationTile(
BuildContext context,
StopLocation station,
RmvFavoritesController favCtrl, {
required bool isFavorite,
}) => ListTile(
leading: CenteredLeading(
Icon(isFavorite ? Icons.star : Icons.directions_transit),
),
title: Text(station.name),
subtitle: station.description == null ? null : Text(station.description!),
trailing: IconButton(
icon: Icon(
favCtrl.isFavorite(station) ? Icons.star : Icons.star_border,
),
tooltip: favCtrl.isFavorite(station)
? 'Favorit entfernen'
: 'Als Favorit speichern',
onPressed: () => favCtrl.toggleFavorite(station),
),
onTap: () => AppRoutes.openRmvStationDetail(context, station),
);
Future<void> _confirmClearRecents(
BuildContext context,
RmvFavoritesController favCtrl,
) async {
ConfirmDialog(
title: 'Verlauf leeren?',
content:
'Die zuletzt verwendeten Stationen werden aus der Übersicht entfernt. Favoriten bleiben bestehen.',
confirmButton: 'Leeren',
onConfirm: () => favCtrl.clearRecents(),
).asDialog(context);
}
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../extensions/date_time.dart';
import '../../../../routing/app_routes.dart';
import '../widgets/leg_tile.dart';
import '../widgets/trip_tile.dart';
class TripDetailView extends StatelessWidget {
final Trip trip;
const TripDetailView({super.key, required this.trip});
@override
Widget build(BuildContext context) {
final first = trip.legs.isEmpty ? null : trip.legs.first;
final last = trip.legs.isEmpty ? null : trip.legs.last;
final duration = trip.realDuration ?? trip.duration;
final transfers =
trip.transferCount ?? _journeyLegs(trip).length.clamp(1, 99) - 1;
return Scaffold(
appBar: AppBar(
title: first == null
? const Text('Verbindung')
: Text('${first.origin.name}${last!.destination.name}'),
),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: _summary(context, duration, transfers, first, last),
),
const Divider(height: 24),
...trip.legs.map(
(l) => LegTile(
leg: l,
onShowJourneyDetail: l.journeyRef == null
? null
: () => AppRoutes.openRmvJourneyDetail(
context,
l.journeyRef!,
date: l.origin.scheduledTime,
),
),
),
],
),
);
}
Widget _summary(
BuildContext context,
Duration? duration,
int transfers,
Leg? first,
Leg? last,
) {
if (first == null || last == null) return const SizedBox.shrink();
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
first.origin.scheduledTime.formatDateRelativeShort(),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${first.origin.scheduledTime.formatHm()} ${last.destination.scheduledTime.formatHm()}',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Spacer(),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (duration != null)
Row(
children: [
const Icon(Icons.schedule, size: 14),
const SizedBox(width: 4),
Text(formatTripDuration(duration)),
],
),
const SizedBox(height: 2),
Row(
children: [
const Icon(Icons.swap_horiz, size: 14),
const SizedBox(width: 4),
Text(
transfers == 0
? 'Direkt'
: '$transfers Umstieg${transfers > 1 ? 'e' : ''}',
),
],
),
],
),
],
);
}
Iterable<Leg> _journeyLegs(Trip t) =>
t.legs.where((l) => l.type == LegType.journey);
}
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../extensions/date_time.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../widgets/trip_tile.dart';
class TripResultsView extends StatefulWidget {
final StopLocation from;
final StopLocation to;
final DateTime? when;
final bool byArrival;
const TripResultsView({
super.key,
required this.from,
required this.to,
this.when,
this.byArrival = false,
});
@override
State<TripResultsView> createState() => _TripResultsViewState();
}
class _TripResultsViewState extends State<TripResultsView> {
final RmvRepository _repo = RmvRepository();
final List<Trip> _trips = [];
String? _scrollLater;
String? _scrollEarlier;
bool _loading = true;
bool _loadingMore = false;
Object? _error;
@override
void initState() {
super.initState();
_initial();
}
Future<void> _initial() async {
setState(() {
_loading = true;
_error = null;
_trips.clear();
_scrollEarlier = null;
_scrollLater = null;
});
try {
final r = await _repo.searchTrips(
fromStopId: widget.from.id,
toStopId: widget.to.id,
when: widget.when,
searchByArrival: widget.byArrival,
);
if (!mounted) return;
setState(() {
_trips.addAll(r.trips);
_scrollEarlier = r.scrollContextEarlier;
_scrollLater = r.scrollContextLater;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
Future<void> _loadMore({required bool later}) async {
final ctx = later ? _scrollLater : _scrollEarlier;
if (ctx == null || _loadingMore) return;
setState(() => _loadingMore = true);
try {
final r = await _repo.moreTrips(ctx);
if (!mounted) return;
setState(() {
if (later) {
_trips.addAll(r.trips);
} else {
_trips.insertAll(0, r.trips);
}
if (r.scrollContextEarlier != null) {
_scrollEarlier = r.scrollContextEarlier;
}
if (r.scrollContextLater != null) {
_scrollLater = r.scrollContextLater;
}
_loadingMore = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorToUserMessage(e))),
);
}
}
@override
Widget build(BuildContext context) {
final whenLabel = widget.when == null
? 'jetzt'
: '${widget.byArrival ? 'an' : 'ab'} ${widget.when!.formatDateTime()}';
return Scaffold(
appBar: AppBar(
title: Text('${widget.from.name}${widget.to.name}'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(24),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
whenLabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white70,
),
),
),
),
),
),
body: _body(),
);
}
Widget _body() {
if (_loading) return const Center(child: AppProgressIndicator.large());
final err = _error;
if (err != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorToUserMessage(err),
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.refresh),
onPressed: _initial,
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (_trips.isEmpty) {
return const Center(child: Text('Keine Verbindungen gefunden.'));
}
return RefreshIndicator(
onRefresh: _initial,
child: ListView.separated(
itemCount: _trips.length + 2,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
if (i == 0) {
return _scrollButton(
icon: Icons.arrow_upward,
label: 'Frühere Verbindungen',
enabled: _scrollEarlier != null,
onTap: () => _loadMore(later: false),
);
}
if (i == _trips.length + 1) {
return _scrollButton(
icon: Icons.arrow_downward,
label: 'Spätere Verbindungen',
enabled: _scrollLater != null,
onTap: () => _loadMore(later: true),
);
}
final trip = _trips[i - 1];
return TripTile(
trip: trip,
onTap: () => AppRoutes.openRmvTripDetail(context, trip),
);
},
),
);
}
Widget _scrollButton({
required IconData icon,
required String label,
required bool enabled,
required VoidCallback onTap,
}) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: OutlinedButton.icon(
icon: _loadingMore
? const AppProgressIndicator.small()
: Icon(icon),
label: Text(label),
onPressed: enabled && !_loadingMore ? onTap : null,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(40),
),
),
);
}
@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/rmv_settings.dart';
import '../../../../storage/settings.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../favorites_controller.dart';
import '../widgets/station_picker_sheet.dart';
import '../widgets/when_picker.dart';
class TripSearchTab extends StatefulWidget {
const TripSearchTab({super.key});
@override
State<TripSearchTab> createState() => _TripSearchTabState();
}
class _TripSearchTabState extends State<TripSearchTab> {
StopLocation? _from;
StopLocation? _to;
DateTime? _when;
bool _byArrival = false;
Future<void> _pickFrom() async {
final s = await showStationPickerSheet(
context,
title: 'Von welcher Station?',
);
if (s != null) setState(() => _from = s);
}
Future<void> _pickTo() async {
final s = await showStationPickerSheet(
context,
title: 'Wohin?',
);
if (s != null) setState(() => _to = s);
}
void _swap() {
setState(() {
final tmp = _from;
_from = _to;
_to = tmp;
});
}
void _search(BuildContext context) {
final from = _from;
final to = _to;
if (from == null || to == null) return;
RmvFavoritesController(context.read<SettingsCubit>())
.addRecentTrip(from, to);
AppRoutes.openRmvTripResults(
context,
from: from,
to: to,
when: _when,
byArrival: _byArrival,
);
}
@override
Widget build(BuildContext context) =>
BlocBuilder<SettingsCubit, Settings>(builder: _buildContent);
Widget _buildContent(BuildContext context, Settings settings) {
final canSearch = _from != null && _to != null;
final recents = settings.rmvSettings.recentTripQueries;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_stationField(
label: 'Von',
icon: Icons.trip_origin,
value: _from,
onTap: _pickFrom,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: _stationField(
label: 'Nach',
icon: Icons.place,
value: _to,
onTap: _pickTo,
),
),
IconButton(
icon: const Icon(Icons.swap_vert),
tooltip: 'Start und Ziel tauschen',
onPressed: _from == null && _to == null ? null : _swap,
),
],
),
const SizedBox(height: 12),
WhenPicker(
value: _when,
byArrival: _byArrival,
onValueChanged: (v) => setState(() => _when = v),
onByArrivalChanged: (v) => setState(() => _byArrival = v),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.search),
label: const Text('Verbindungen suchen'),
onPressed: canSearch ? () => _search(context) : null,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
],
),
),
const Divider(height: 32),
if (recents.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Center(
child: Text(
'Noch keine Suchen. Wähle Start und Ziel, um die erste Verbindung zu suchen.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
)
else
_recentsSection(context, recents),
],
);
}
Widget _stationField({
required String label,
required IconData icon,
required StopLocation? value,
required VoidCallback onTap,
}) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
child: Text(
value?.name ?? 'Station wählen',
style: value == null
? TextStyle(color: Theme.of(context).hintColor)
: null,
),
),
);
Widget _recentsSection(
BuildContext context,
List<RecentTripQuery> recents,
) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
'Letzte Suchen',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
),
IconButton(
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: 'Alle löschen',
onPressed: () => ConfirmDialog(
title: 'Suchverlauf leeren?',
content:
'Die letzten Verbindungssuchen werden entfernt. Favoriten bleiben bestehen.',
confirmButton: 'Leeren',
onConfirm: () => RmvFavoritesController(
context.read<SettingsCubit>(),
).clearRecentTrips(),
).asDialog(context),
),
],
),
),
...recents.map(
(q) => ListTile(
leading: const CenteredLeading(Icon(Icons.history)),
title: Text('${q.from.name}${q.to.name}'),
onTap: () => setState(() {
_from = q.from;
_to = q.to;
}),
),
),
],
);
}
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import 'product_chip.dart';
import 'realtime_time.dart';
/// Renders a single departure or arrival row. Used in the station detail view.
class DepartureArrivalTile extends StatelessWidget {
final Product? product;
final String name;
/// Direction (for departures) or origin (for arrivals).
final String towards;
final DateTime scheduled;
final DateTime? realtime;
final int? delayMinutes;
final String? track;
final String? realTrack;
final bool cancelled;
final VoidCallback? onTap;
const DepartureArrivalTile({
super.key,
required this.product,
required this.name,
required this.towards,
required this.scheduled,
this.realtime,
this.delayMinutes,
this.track,
this.realTrack,
this.cancelled = false,
this.onTap,
});
factory DepartureArrivalTile.fromDeparture(
Departure d, {
VoidCallback? onTap,
}) => DepartureArrivalTile(
product: d.product,
name: d.name,
towards: 'nach ${d.direction}',
scheduled: d.scheduledTime,
realtime: d.realTime,
delayMinutes: d.delayMinutes,
track: d.track,
realTrack: d.realTrack,
cancelled: d.cancelled,
onTap: onTap,
);
factory DepartureArrivalTile.fromArrival(
Arrival a, {
VoidCallback? onTap,
}) => DepartureArrivalTile(
product: a.product,
name: a.name,
towards: 'von ${a.origin}',
scheduled: a.scheduledTime,
realtime: a.realTime,
delayMinutes: a.delayMinutes,
track: a.track,
realTrack: a.realTrack,
cancelled: a.cancelled,
onTap: onTap,
);
@override
Widget build(BuildContext context) {
final effectiveTrack = (realTrack?.isNotEmpty ?? false)
? realTrack!
: (track ?? '');
final trackChanged =
realTrack != null && track != null && realTrack != track;
return ListTile(
onTap: onTap,
leading: SizedBox(
width: 72,
child: ProductChip(product: product, fallbackLabel: name),
),
title: Text(
towards,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
decoration: cancelled ? TextDecoration.lineThrough : null,
),
),
subtitle: effectiveTrack.isEmpty
? null
: Row(
children: [
Icon(
Icons.directions_transit,
size: 14,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
'Gleis $effectiveTrack',
style: TextStyle(
color: trackChanged
? Colors.red
: Theme.of(context).colorScheme.secondary,
fontWeight: trackChanged ? FontWeight.bold : null,
),
),
],
),
trailing: RealtimeTime(
scheduled: scheduled,
realtime: realtime,
delayMinutes: delayMinutes,
cancelled: cancelled,
style: Theme.of(context).textTheme.titleMedium,
),
);
}
}
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../extensions/date_time.dart';
import 'product_chip.dart';
import 'realtime_time.dart';
/// Renders a single [Leg] of a trip with header (line/direction), origin and
/// destination times and (optionally) the list of intermediate stops.
class LegTile extends StatelessWidget {
final Leg leg;
final VoidCallback? onShowJourneyDetail;
const LegTile({super.key, required this.leg, this.onShowJourneyDetail});
@override
Widget build(BuildContext context) {
final isWalk =
leg.type == LegType.walk || leg.type == LegType.transfer;
final cancelled = leg.cancelled || leg.partCancelled;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(context, isWalk: isWalk, cancelled: cancelled),
_endpoint(
context,
label: leg.origin.name,
scheduled: leg.origin.scheduledTime,
realtime: leg.origin.realTime,
delayMinutes: leg.origin.delayMinutes?.toInt(),
track: (leg.origin.realTrack?.isNotEmpty ?? false)
? leg.origin.realTrack
: leg.origin.track,
icon: Icons.trip_origin,
cancelled: cancelled,
),
if (leg.stops.length > 2)
_stopsExpander(context, leg.stops),
_endpoint(
context,
label: leg.destination.name,
scheduled: leg.destination.scheduledTime,
realtime: leg.destination.realTime,
delayMinutes: leg.destination.delayMinutes?.toInt(),
track: (leg.destination.realTrack?.isNotEmpty ?? false)
? leg.destination.realTrack
: leg.destination.track,
icon: Icons.place,
cancelled: cancelled,
),
],
),
);
}
Widget _header(
BuildContext context, {
required bool isWalk,
required bool cancelled,
}) {
final headlineStyle = Theme.of(context).textTheme.titleSmall?.copyWith(
decoration: cancelled ? TextDecoration.lineThrough : null,
);
final title = isWalk
? 'Fußweg'
: (leg.direction != null
? '${leg.name ?? ''}${leg.direction}'
: (leg.name ?? ''));
final canOpenJourney = leg.journeyRef != null && !isWalk;
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
if (!isWalk) ProductChip(product: leg.product, fallbackLabel: leg.name),
if (!isWalk) const SizedBox(width: 8),
if (isWalk)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.directions_walk),
),
Expanded(
child: Text(
title,
style: headlineStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (leg.duration != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'${leg.duration!.inMinutes} min',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (canOpenJourney)
IconButton(
icon: const Icon(Icons.list_alt),
tooltip: 'Alle Halte',
onPressed: onShowJourneyDetail,
),
],
),
);
}
Widget _endpoint(
BuildContext context, {
required String label,
required DateTime scheduled,
DateTime? realtime,
int? delayMinutes,
String? track,
required IconData icon,
required bool cancelled,
}) => ListTile(
leading: Icon(icon, size: 20),
title: Text(
label,
style: TextStyle(
decoration: cancelled ? TextDecoration.lineThrough : null,
),
),
subtitle: (track != null && track.isNotEmpty)
? Text('Gleis $track')
: null,
trailing: RealtimeTime(
scheduled: scheduled,
realtime: realtime,
delayMinutes: delayMinutes,
cancelled: cancelled,
),
dense: true,
);
Widget _stopsExpander(BuildContext context, List<JourneyStop> stops) {
final intermediate = stops.length > 2
? stops.sublist(1, stops.length - 1)
: const <JourneyStop>[];
if (intermediate.isEmpty) return const SizedBox.shrink();
return ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(
'${intermediate.length} Zwischenhalt${intermediate.length > 1 ? 'e' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
children: intermediate
.map(
(s) => ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: const Icon(Icons.fiber_manual_record, size: 10),
title: Text(s.name),
trailing: Text(_stopTime(s)),
),
)
.toList(),
);
}
String _stopTime(JourneyStop s) {
final dep = s.realDeparture ?? s.scheduledDeparture;
final arr = s.realArrival ?? s.scheduledArrival;
if (arr != null && dep != null && arr != dep) {
return '${arr.formatHm()} / ${dep.formatHm()}';
}
final t = dep ?? arr;
return t?.formatHm() ?? '';
}
}
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
/// Renders a transit line/product as a compact, colored chip
/// (e.g. `U7`, `S3`, `RB51`, `ICE`). Colour is derived from the category code
/// so the same line consistently has the same colour.
class ProductChip extends StatelessWidget {
final Product? product;
final String? fallbackLabel;
const ProductChip({super.key, required this.product, this.fallbackLabel});
@override
Widget build(BuildContext context) {
final label = _label();
if (label == null || label.isEmpty) return const SizedBox.shrink();
final color = _colorFor(product?.category, product?.categoryCode);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
);
}
String? _label() {
final p = product;
if (p == null) return fallbackLabel;
if (p.line != null && p.line!.isNotEmpty) return p.line;
if (p.displayNumber != null && p.displayNumber!.isNotEmpty) {
return '${p.category ?? ''}${p.displayNumber}'.trim();
}
if (p.name != null && p.name!.isNotEmpty) return p.name;
return fallbackLabel;
}
Color _colorFor(String? category, String? code) {
final key = (category ?? code ?? '').toLowerCase();
if (key.startsWith('ice')) return const Color(0xFFD32F2F);
if (key.startsWith('ic') || key.startsWith('ec')) {
return const Color(0xFFE57373);
}
if (key.startsWith('s-bahn') || key == 's') return const Color(0xFF2E7D32);
if (key.startsWith('u-bahn') || key == 'u') return const Color(0xFF1565C0);
if (key.startsWith('tram') || key.startsWith('strab')) {
return const Color(0xFFEF6C00);
}
if (key.startsWith('bus')) return const Color(0xFF6A1B9A);
if (key.startsWith('rb') || key.startsWith('re')) {
return const Color(0xFF455A64);
}
return const Color(0xFF37474F);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.dart';
/// Shows a scheduled time with optional realtime delay overlay.
///
/// Examples:
/// - on-time: `14:35`
/// - 2 minutes late: `14:35` + green/red `+2'` chip
/// - cancelled: scheduled time struck through, red `Ausfall` chip
class RealtimeTime extends StatelessWidget {
final DateTime scheduled;
final DateTime? realtime;
final int? delayMinutes;
final bool cancelled;
final TextStyle? style;
const RealtimeTime({
super.key,
required this.scheduled,
this.realtime,
this.delayMinutes,
this.cancelled = false,
this.style,
});
@override
Widget build(BuildContext context) {
final base = style ?? Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
final scheduledText = Text(
scheduled.formatHm(),
style: base.copyWith(
decoration: cancelled ? TextDecoration.lineThrough : null,
),
);
if (cancelled) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
scheduledText,
const SizedBox(width: 6),
_badge(context, 'Ausfall', Colors.red),
],
);
}
final delay = delayMinutes;
if (delay != null && delay != 0) {
final positive = delay > 0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
scheduledText,
const SizedBox(width: 4),
Text(
'${positive ? '+' : ''}$delay\'',
style: base.copyWith(
color: positive ? Colors.red : Colors.green,
fontWeight: FontWeight.bold,
),
),
],
);
}
return scheduledText;
}
Widget _badge(BuildContext context, String text, Color color) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
);
}
@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../utils/debouncer.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/centered_leading.dart';
import '../favorites_controller.dart';
/// Modal search sheet for picking a [StopLocation]. Shows favorites + recents
/// when the search field is empty, switches to live search results as soon as
/// the user types. Returns the chosen stop via [Navigator.pop], or `null` if
/// the user dismisses the sheet.
Future<StopLocation?> showStationPickerSheet(
BuildContext context, {
String title = 'Station auswählen',
}) => showModalBottomSheet<StopLocation>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (sheetCtx) => _StationPickerSheet(title: title),
);
class _StationPickerSheet extends StatefulWidget {
final String title;
const _StationPickerSheet({required this.title});
@override
State<_StationPickerSheet> createState() => _StationPickerSheetState();
}
class _StationPickerSheetState extends State<_StationPickerSheet> {
static const _debounceTag = 'rmv_station_search';
final TextEditingController _controller = TextEditingController();
final RmvRepository _repo = RmvRepository();
List<StopLocation>? _results;
bool _loading = false;
Object? _error;
String _query = '';
@override
void dispose() {
Debouncer.cancel(_debounceTag);
_controller.dispose();
super.dispose();
}
void _onChanged(String value) {
final trimmed = value.trim();
setState(() => _query = trimmed);
if (trimmed.length < 2) {
setState(() {
_results = null;
_error = null;
_loading = false;
});
Debouncer.cancel(_debounceTag);
return;
}
Debouncer.debounce(_debounceTag, const Duration(milliseconds: 300), () {
_runSearch(trimmed);
});
}
Future<void> _runSearch(String q) async {
setState(() {
_loading = true;
_error = null;
});
try {
final results = await _repo.searchStops(q, max: 25);
if (!mounted || _query != q) return;
setState(() {
_results = results;
_loading = false;
});
} catch (e) {
if (!mounted || _query != q) return;
setState(() {
_error = e;
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final favorites =
settings.val().rmvSettings.favoriteStations;
final recents = settings.val().rmvSettings.recentStations;
final viewInsets = MediaQuery.of(context).viewInsets;
return Padding(
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _controller,
autofocus: true,
onChanged: _onChanged,
decoration: InputDecoration(
hintText: 'Station suchen…',
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
_onChanged('');
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
minHeight: 200,
),
child: _body(favorites, recents),
),
],
),
);
}
Widget _body(List<StopLocation> favorites, List<StopLocation> recents) {
if (_loading) {
return const Center(child: AppProgressIndicator.medium());
}
final err = _error;
if (err != null) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
errorToUserMessage(err),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
);
}
final results = _results;
if (results != null) {
if (results.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Station für "$_query" gefunden.',
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
}
return ListView(
children: results
.map((s) => _tile(s, leadingIcon: Icons.directions_transit))
.toList(),
);
}
// Empty query → favorites + recents.
final widgets = <Widget>[];
if (favorites.isNotEmpty) {
widgets.add(_sectionHeader('Favoriten'));
widgets.addAll(
favorites.map((s) => _tile(s, leadingIcon: Icons.star)),
);
}
if (recents.isNotEmpty) {
widgets.add(_sectionHeader('Zuletzt verwendet'));
widgets.addAll(
recents.map((s) => _tile(s, leadingIcon: Icons.history)),
);
}
if (widgets.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Tippe oben einen Stationsnamen ein, um die RMV-Datenbank zu durchsuchen.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
return ListView(children: widgets);
}
Widget _sectionHeader(String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
);
Widget _tile(StopLocation stop, {required IconData leadingIcon}) => ListTile(
leading: CenteredLeading(Icon(leadingIcon)),
title: Text(stop.name),
subtitle: stop.description == null ? null : Text(stop.description!),
onTap: () {
RmvFavoritesController(context.read<SettingsCubit>()).addRecent(stop);
Navigator.of(context).pop(stop);
},
);
}
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../../../api/connect/rmv/rmv_models.dart';
import '../../../../extensions/date_time.dart';
import 'product_chip.dart';
import 'realtime_time.dart';
/// Compact summary of a [Trip] used in the trip results list.
class TripTile extends StatelessWidget {
final Trip trip;
final VoidCallback? onTap;
const TripTile({super.key, required this.trip, this.onTap});
@override
Widget build(BuildContext context) {
final firstLeg = trip.legs.isEmpty ? null : trip.legs.first;
final lastLeg = trip.legs.isEmpty ? null : trip.legs.last;
if (firstLeg == null || lastLeg == null) {
return const ListTile(title: Text('Verbindung ohne Halt'));
}
final scheduledStart = firstLeg.origin.scheduledTime;
final scheduledEnd = lastLeg.destination.scheduledTime;
final cancelled =
trip.legs.any((l) => l.cancelled || l.partCancelled);
final transfers = trip.transferCount ?? _countTransfers(trip);
final duration = trip.realDuration ?? trip.duration;
final productChips = trip.legs
.where((l) => l.type == LegType.journey && l.product != null)
.map((l) => Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductChip(product: l.product, fallbackLabel: l.name),
))
.toList();
return ListTile(
onTap: onTap,
isThreeLine: true,
title: Row(
children: [
RealtimeTime(
scheduled: scheduledStart,
realtime: firstLeg.origin.realTime,
delayMinutes: firstLeg.origin.delayMinutes?.toInt(),
cancelled: cancelled,
style: Theme.of(context).textTheme.titleMedium,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(''),
),
RealtimeTime(
scheduled: scheduledEnd,
realtime: lastLeg.destination.realTime,
delayMinutes: lastLeg.destination.delayMinutes?.toInt(),
cancelled: cancelled,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(runSpacing: 4, children: productChips),
const SizedBox(height: 4),
Row(
children: [
if (duration != null) ...[
const Icon(Icons.schedule, size: 14),
const SizedBox(width: 2),
Text(_formatDuration(duration)),
const SizedBox(width: 12),
],
const Icon(Icons.swap_horiz, size: 14),
const SizedBox(width: 2),
Text(
transfers == 0
? 'Direkt'
: '$transfers Umstieg${transfers > 1 ? 'e' : ''}',
),
],
),
],
),
trailing: const Icon(Icons.chevron_right),
);
}
int _countTransfers(Trip trip) {
final journeyLegs =
trip.legs.where((l) => l.type == LegType.journey).length;
return journeyLegs <= 1 ? 0 : journeyLegs - 1;
}
}
String _formatDuration(Duration d) {
final hours = d.inHours;
final minutes = d.inMinutes.remainder(60);
if (hours == 0) return '$minutes min';
return '$hours h ${minutes.toString().padLeft(2, '0')} min';
}
/// Re-export for trip detail screen.
String formatTripDuration(Duration d) => _formatDuration(d);
/// Helper used in date headers on the trip results list.
String formatTripDateHeader(DateTime when) => when.formatDateRelativeShort();
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.dart';
/// Returns the "depart at / arrive by" time and the AB/AN-toggle. `null` for
/// [value] means "now" — the API treats an empty `when` parameter as the
/// current time.
class WhenPicker extends StatelessWidget {
final DateTime? value;
final bool byArrival;
final ValueChanged<DateTime?> onValueChanged;
final ValueChanged<bool> onByArrivalChanged;
const WhenPicker({
super.key,
required this.value,
required this.byArrival,
required this.onValueChanged,
required this.onByArrivalChanged,
});
@override
Widget build(BuildContext context) {
final label = value == null
? 'Jetzt'
: value!.formatDateRelativeShort() == 'Heute'
? value!.formatHm()
: '${value!.formatDateRelativeShort()} ${value!.formatHm()}';
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.schedule),
label: Text(label),
onPressed: () => _pick(context),
),
),
const SizedBox(width: 8),
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Ab')),
ButtonSegment(value: true, label: Text('An')),
],
selected: {byArrival},
onSelectionChanged: (s) => onByArrivalChanged(s.first),
),
if (value != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Zurück auf "Jetzt"',
onPressed: () => onValueChanged(null),
),
],
],
);
}
Future<void> _pick(BuildContext context) async {
final now = DateTime.now();
final initial = value ?? now;
final date = await showDatePicker(
context: context,
initialDate: initial,
firstDate: now.subtract(const Duration(days: 7)),
lastDate: now.add(const Duration(days: 365)),
);
if (date == null) return;
if (!context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: initial.hour, minute: initial.minute),
);
if (time == null) return;
onValueChanged(
DateTime(date.year, date.month, date.day, time.hour, time.minute),
);
}
}
@@ -9,6 +9,7 @@ import '../../../../storage/file_view_settings.dart';
import '../../../../storage/holidays_settings.dart'; import '../../../../storage/holidays_settings.dart';
import '../../../../storage/modules_settings.dart'; import '../../../../storage/modules_settings.dart';
import '../../../../storage/notification_settings.dart'; import '../../../../storage/notification_settings.dart';
import '../../../../storage/rmv_settings.dart';
import '../../../../storage/settings.dart'; import '../../../../storage/settings.dart';
import '../../../../storage/talk_settings.dart'; import '../../../../storage/talk_settings.dart';
import '../../../../storage/timetable_settings.dart'; import '../../../../storage/timetable_settings.dart';
@@ -29,6 +30,7 @@ class DefaultSettings {
Modules.gradeAveragesCalculator, Modules.gradeAveragesCalculator,
Modules.holidays, Modules.holidays,
Modules.marianumDates, Modules.marianumDates,
Modules.rmv,
], ],
hiddenModules: [], hiddenModules: [],
autoFillBottomBar: true, autoFillBottomBar: true,
@@ -53,6 +55,7 @@ class DefaultSettings {
dismissedDisclaimer: false, dismissedDisclaimer: false,
showPastEvents: false, showPastEvents: false,
), ),
rmvSettings: RmvSettings(),
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings( notificationSettings: NotificationSettings(
askUsageDismissed: false, askUsageDismissed: false,
+1
View File
@@ -48,6 +48,7 @@ dependencies:
scrollable_positioned_list: ^0.3.8 scrollable_positioned_list: ^0.3.8
flutter_split_view: ^0.1.2 flutter_split_view: ^0.1.2
flutter_svg: ^2.0.10 flutter_svg: ^2.0.10
geolocator: ^14.0.0
freezed_annotation: ^3.1.0 freezed_annotation: ^3.1.0
http: ^1.3.0 http: ^1.3.0
hydrated_bloc: ^11.0.0 hydrated_bloc: ^11.0.0
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/connect/rmv/iso_duration.dart';
void main() {
group('IsoDuration.fromJson', () {
test('null and empty return null', () {
expect(IsoDuration.fromJson(null), isNull);
expect(IsoDuration.fromJson(''), isNull);
});
test('hours-minutes-seconds parse correctly', () {
expect(
IsoDuration.fromJson('PT1H30M15S'),
const Duration(hours: 1, minutes: 30, seconds: 15),
);
});
test('hours only', () {
expect(IsoDuration.fromJson('PT2H'), const Duration(hours: 2));
});
test('minutes only', () {
expect(IsoDuration.fromJson('PT45M'), const Duration(minutes: 45));
});
test('seconds with fraction', () {
expect(
IsoDuration.fromJson('PT30.5S'),
const Duration(milliseconds: 30500),
);
});
test('invalid string returns null', () {
expect(IsoDuration.fromJson('not a duration'), isNull);
});
});
group('IsoDuration.toJson', () {
test('null returns null', () => expect(IsoDuration.toJson(null), isNull));
test('zero duration formats as PT0S', () {
expect(IsoDuration.toJson(Duration.zero), 'PT0S');
});
test('hours and minutes only formats without seconds', () {
expect(
IsoDuration.toJson(const Duration(hours: 1, minutes: 30)),
'PT1H30M',
);
});
test('full duration roundtrips through parse', () {
const original = Duration(hours: 2, minutes: 15, seconds: 7);
expect(IsoDuration.fromJson(IsoDuration.toJson(original)), original);
});
});
}
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/connect/errors/rmv_upstream_exception.dart';
void main() {
group('RmvUpstreamException', () {
test('H390 maps to no-connection message', () {
final e = RmvUpstreamException(errorCode: 'H390');
expect(e.userMessage, contains('Keine Verbindung'));
});
test('H891 maps to invalid-station message', () {
final e = RmvUpstreamException(errorCode: 'H891');
expect(e.userMessage, contains('ungültig'));
});
test('unknown code falls through to a generic but specific message', () {
final e = RmvUpstreamException(errorCode: 'HXYZ');
expect(e.userMessage, contains('HXYZ'));
});
test('null code yields the generic upstream message', () {
final e = RmvUpstreamException(errorCode: null);
expect(e.userMessage, contains('keine Antwort'));
});
});
}