Compare commits
2 Commits
develop
...
develop-rmv
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d6b3410e | |||
| 067012cc84 |
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>);
|
||||||
|
}
|
||||||
@@ -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
@@ -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(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'nominatim_result.freezed.dart';
|
||||||
|
part 'nominatim_result.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class NominatimResult with _$NominatimResult {
|
||||||
|
const factory NominatimResult({
|
||||||
|
required String displayName,
|
||||||
|
required double lat,
|
||||||
|
required double lon,
|
||||||
|
}) = _NominatimResult;
|
||||||
|
|
||||||
|
factory NominatimResult.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$NominatimResultFromJson(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 'nominatim_result.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$NominatimResult {
|
||||||
|
|
||||||
|
String get displayName; double get lat; double get lon;
|
||||||
|
/// Create a copy of NominatimResult
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$NominatimResultCopyWith<NominatimResult> get copyWith => _$NominatimResultCopyWithImpl<NominatimResult>(this as NominatimResult, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this NominatimResult to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $NominatimResultCopyWith<$Res> {
|
||||||
|
factory $NominatimResultCopyWith(NominatimResult value, $Res Function(NominatimResult) _then) = _$NominatimResultCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String displayName, double lat, double lon
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$NominatimResultCopyWithImpl<$Res>
|
||||||
|
implements $NominatimResultCopyWith<$Res> {
|
||||||
|
_$NominatimResultCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final NominatimResult _self;
|
||||||
|
final $Res Function(NominatimResult) _then;
|
||||||
|
|
||||||
|
/// Create a copy of NominatimResult
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [NominatimResult].
|
||||||
|
extension NominatimResultPatterns on NominatimResult {
|
||||||
|
/// 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( _NominatimResult value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _NominatimResult() 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( _NominatimResult value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _NominatimResult():
|
||||||
|
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( _NominatimResult value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _NominatimResult() 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( String displayName, double lat, double lon)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _NominatimResult() when $default != null:
|
||||||
|
return $default(_that.displayName,_that.lat,_that.lon);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( String displayName, double lat, double lon) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _NominatimResult():
|
||||||
|
return $default(_that.displayName,_that.lat,_that.lon);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( String displayName, double lat, double lon)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _NominatimResult() when $default != null:
|
||||||
|
return $default(_that.displayName,_that.lat,_that.lon);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _NominatimResult implements NominatimResult {
|
||||||
|
const _NominatimResult({required this.displayName, required this.lat, required this.lon});
|
||||||
|
factory _NominatimResult.fromJson(Map<String, dynamic> json) => _$NominatimResultFromJson(json);
|
||||||
|
|
||||||
|
@override final String displayName;
|
||||||
|
@override final double lat;
|
||||||
|
@override final double lon;
|
||||||
|
|
||||||
|
/// Create a copy of NominatimResult
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$NominatimResultCopyWith<_NominatimResult> get copyWith => __$NominatimResultCopyWithImpl<_NominatimResult>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$NominatimResultToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$NominatimResultCopyWith<$Res> implements $NominatimResultCopyWith<$Res> {
|
||||||
|
factory _$NominatimResultCopyWith(_NominatimResult value, $Res Function(_NominatimResult) _then) = __$NominatimResultCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String displayName, double lat, double lon
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$NominatimResultCopyWithImpl<$Res>
|
||||||
|
implements _$NominatimResultCopyWith<$Res> {
|
||||||
|
__$NominatimResultCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _NominatimResult _self;
|
||||||
|
final $Res Function(_NominatimResult) _then;
|
||||||
|
|
||||||
|
/// Create a copy of NominatimResult
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
|
||||||
|
return _then(_NominatimResult(
|
||||||
|
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'nominatim_result.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_NominatimResult _$NominatimResultFromJson(Map<String, dynamic> json) =>
|
||||||
|
_NominatimResult(
|
||||||
|
displayName: json['displayName'] as String,
|
||||||
|
lat: (json['lat'] as num).toDouble(),
|
||||||
|
lon: (json['lon'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$NominatimResultToJson(_NominatimResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'displayName': instance.displayName,
|
||||||
|
'lat': instance.lat,
|
||||||
|
'lon': instance.lon,
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../errors/network_exception.dart';
|
||||||
|
import '../errors/parse_exception.dart';
|
||||||
|
import '../errors/server_exception.dart';
|
||||||
|
import 'nominatim_result.dart';
|
||||||
|
|
||||||
|
/// Tiny wrapper around the public Nominatim geocoder. Only used in the
|
||||||
|
/// commute-settings flow to look up a home address; not called from any
|
||||||
|
/// hot path. The User-Agent header is **required** by the Nominatim usage
|
||||||
|
/// policy — without it the service throttles/blocks the client.
|
||||||
|
class NominatimSearch {
|
||||||
|
static const _userAgent = 'MarianumMobile/1.0 (contact@elias-mueller.com)';
|
||||||
|
static final Uri _base = Uri.parse('https://nominatim.openstreetmap.org/search');
|
||||||
|
|
||||||
|
/// Returns up to [limit] geocoded matches for the user-typed [query].
|
||||||
|
Future<List<NominatimResult>> run(String query, {int limit = 5}) async {
|
||||||
|
final uri = _base.replace(
|
||||||
|
queryParameters: {
|
||||||
|
'q': query,
|
||||||
|
'format': 'json',
|
||||||
|
'limit': limit.toString(),
|
||||||
|
'addressdetails': '0',
|
||||||
|
'accept-language': 'de',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http
|
||||||
|
.get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'application/json'})
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
|
||||||
|
} on TimeoutException catch (e) {
|
||||||
|
throw NetworkException.timeout(technicalDetails: 'nominatim: $e');
|
||||||
|
} on http.ClientException catch (e) {
|
||||||
|
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode > 299) {
|
||||||
|
throw ServerException(
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
technicalDetails: 'nominatim HTTP ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final raw = jsonDecode(utf8.decode(response.bodyBytes)) as List;
|
||||||
|
return raw
|
||||||
|
.map((e) => _resultFromRaw(e as Map<String, dynamic>))
|
||||||
|
.toList(growable: false);
|
||||||
|
} catch (e) {
|
||||||
|
throw ParseException(technicalDetails: 'nominatim assemble: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NominatimResult _resultFromRaw(Map<String, dynamic> json) {
|
||||||
|
// Nominatim returns lat/lon as strings, not numbers. Normalise here.
|
||||||
|
final lat = double.parse(json['lat'].toString());
|
||||||
|
final lon = double.parse(json['lon'].toString());
|
||||||
|
return NominatimResult(
|
||||||
|
displayName: json['display_name'] as String? ?? '?',
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -30,6 +31,7 @@ import 'state/app/modules/account/bloc/account_state.dart';
|
|||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||||
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
|
import 'state/app/modules/commute/bloc/commute_cubit.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import 'storage/settings.dart';
|
import 'storage/settings.dart';
|
||||||
@@ -158,6 +160,7 @@ Future<void> main() async {
|
|||||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
),
|
),
|
||||||
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
||||||
|
BlocProvider<CommuteCubit>(create: (_) => CommuteCubit()),
|
||||||
],
|
],
|
||||||
child: const Main(),
|
child: const Main(),
|
||||||
),
|
),
|
||||||
@@ -251,6 +254,7 @@ class _MainState extends State<Main> {
|
|||||||
final chatListBloc = context.read<ChatListBloc>();
|
final chatListBloc = context.read<ChatListBloc>();
|
||||||
final chatBloc = context.read<ChatBloc>();
|
final chatBloc = context.read<ChatBloc>();
|
||||||
final breakerBloc = context.read<BreakerBloc>();
|
final breakerBloc = context.read<BreakerBloc>();
|
||||||
|
final commuteCubit = context.read<CommuteCubit>();
|
||||||
// Defer the actual wipe until after this frame so the
|
// Defer the actual wipe until after this frame so the
|
||||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
||||||
// is already torn down. Resetting blocs while App is
|
// is already torn down. Resetting blocs while App is
|
||||||
@@ -263,6 +267,7 @@ class _MainState extends State<Main> {
|
|||||||
chatListBloc: chatListBloc,
|
chatListBloc: chatListBloc,
|
||||||
chatBloc: chatBloc,
|
chatBloc: chatBloc,
|
||||||
breakerBloc: breakerBloc,
|
breakerBloc: breakerBloc,
|
||||||
|
commuteCubit: commuteCubit,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -307,6 +312,7 @@ Future<void> _wipeUserState({
|
|||||||
required ChatListBloc chatListBloc,
|
required ChatListBloc chatListBloc,
|
||||||
required ChatBloc chatBloc,
|
required ChatBloc chatBloc,
|
||||||
required BreakerBloc breakerBloc,
|
required BreakerBloc breakerBloc,
|
||||||
|
required CommuteCubit commuteCubit,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Reset user-data blocs whose tree is no longer mounted after the
|
// Reset user-data blocs whose tree is no longer mounted after the
|
||||||
@@ -319,6 +325,8 @@ Future<void> _wipeUserState({
|
|||||||
chatListBloc.reset(),
|
chatListBloc.reset(),
|
||||||
chatBloc.reset(),
|
chatBloc.reset(),
|
||||||
breakerBloc.reset(),
|
breakerBloc.reset(),
|
||||||
|
commuteCubit.reset(),
|
||||||
|
ConnectAuthStore.instance.clear(),
|
||||||
]);
|
]);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,191 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/connect/rmv/rmv_models.dart';
|
||||||
|
import '../../../../../api/errors/error_mapper.dart';
|
||||||
|
import '../../../../../storage/timetable_settings.dart';
|
||||||
|
import '../../../../../view/pages/timetable/data/commute_direction.dart';
|
||||||
|
import '../repository/commute_repository.dart';
|
||||||
|
|
||||||
|
/// First/last-lesson timestamps used as commute anchors.
|
||||||
|
class LessonSpan {
|
||||||
|
final DateTime firstStart;
|
||||||
|
final DateTime lastEnd;
|
||||||
|
const LessonSpan(this.firstStart, this.lastEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable per-day commute snapshot.
|
||||||
|
class CommuteDayEntry {
|
||||||
|
final List<Trip> morning;
|
||||||
|
final List<Trip> evening;
|
||||||
|
final bool loading;
|
||||||
|
final String? errorMessage;
|
||||||
|
final int? loadedAtMs;
|
||||||
|
|
||||||
|
const CommuteDayEntry({
|
||||||
|
this.morning = const [],
|
||||||
|
this.evening = const [],
|
||||||
|
this.loading = false,
|
||||||
|
this.errorMessage,
|
||||||
|
this.loadedAtMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
CommuteDayEntry copyWith({
|
||||||
|
List<Trip>? morning,
|
||||||
|
List<Trip>? evening,
|
||||||
|
bool? loading,
|
||||||
|
String? errorMessage,
|
||||||
|
int? loadedAtMs,
|
||||||
|
}) => CommuteDayEntry(
|
||||||
|
morning: morning ?? this.morning,
|
||||||
|
evening: evening ?? this.evening,
|
||||||
|
loading: loading ?? this.loading,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
loadedAtMs: loadedAtMs ?? this.loadedAtMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the daily commute trips for the currently visible timetable week.
|
||||||
|
/// State is an immutable `Map<DateKey, CommuteDayEntry>` keyed by the local
|
||||||
|
/// date ("yyyy-MM-dd"). Entries older than [_ttl] are reloaded on the next
|
||||||
|
/// [ensureLoaded] call.
|
||||||
|
class CommuteCubit extends Cubit<Map<String, CommuteDayEntry>> {
|
||||||
|
static const Duration _ttl = Duration(minutes: 5);
|
||||||
|
static const int _maxTrips = 3;
|
||||||
|
|
||||||
|
final CommuteRepository _repo = CommuteRepository();
|
||||||
|
final Set<String> _inflight = {};
|
||||||
|
|
||||||
|
CommuteCubit() : super(const {});
|
||||||
|
|
||||||
|
static String keyFor(DateTime day) {
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
return '${day.year}-${two(day.month)}-${two(day.day)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Trip> tripsFor(DateTime day, CommuteDirection direction) {
|
||||||
|
final entry = state[keyFor(day)];
|
||||||
|
if (entry == null) return const [];
|
||||||
|
return switch (direction) {
|
||||||
|
CommuteDirection.toSchool => entry.morning,
|
||||||
|
CommuteDirection.fromSchool => entry.evening,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isLoading(DateTime day) => state[keyFor(day)]?.loading ?? false;
|
||||||
|
String? errorFor(DateTime day) => state[keyFor(day)]?.errorMessage;
|
||||||
|
|
||||||
|
/// Triggers RMV trip lookups for every day in [lessonsByDay]. Skips days
|
||||||
|
/// whose cache is still fresh. Safe to call on every rebuild.
|
||||||
|
void ensureLoaded({
|
||||||
|
required Map<DateTime, LessonSpan> lessonsByDay,
|
||||||
|
required TimetableSettings settings,
|
||||||
|
}) {
|
||||||
|
if (!settings.showCommuteInTimetable) return;
|
||||||
|
final home = settings.homeStation;
|
||||||
|
final school = settings.schoolStation;
|
||||||
|
if (home == null || school == null) return;
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final ttlMs = _ttl.inMilliseconds;
|
||||||
|
|
||||||
|
for (final entry in lessonsByDay.entries) {
|
||||||
|
final day = entry.key;
|
||||||
|
final span = entry.value;
|
||||||
|
final key = keyFor(day);
|
||||||
|
if (_inflight.contains(key)) continue;
|
||||||
|
final existing = state[key];
|
||||||
|
if (existing != null &&
|
||||||
|
existing.loadedAtMs != null &&
|
||||||
|
now - existing.loadedAtMs! < ttlMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_inflight.add(key);
|
||||||
|
unawaited(
|
||||||
|
_loadDay(
|
||||||
|
day: day,
|
||||||
|
span: span,
|
||||||
|
home: home,
|
||||||
|
school: school,
|
||||||
|
bufferMinutes: settings.commuteBufferMinutes,
|
||||||
|
).whenComplete(() => _inflight.remove(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDay({
|
||||||
|
required DateTime day,
|
||||||
|
required LessonSpan span,
|
||||||
|
required StopLocation home,
|
||||||
|
required StopLocation school,
|
||||||
|
required int bufferMinutes,
|
||||||
|
}) async {
|
||||||
|
final key = keyFor(day);
|
||||||
|
_set(key, (e) => e.copyWith(loading: true, errorMessage: null));
|
||||||
|
|
||||||
|
final buffer = Duration(minutes: bufferMinutes);
|
||||||
|
final arrivalDeadline = span.firstStart.subtract(buffer);
|
||||||
|
final departureEarliest = span.lastEnd.add(buffer);
|
||||||
|
|
||||||
|
log(
|
||||||
|
'commute $key request: home=${home.id}/${home.name} → '
|
||||||
|
'school=${school.id}/${school.name} arrive_by=$arrivalDeadline '
|
||||||
|
'depart_from=$departureEarliest',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<List<Trip>>([
|
||||||
|
_repo.findTrips(
|
||||||
|
from: home,
|
||||||
|
to: school,
|
||||||
|
when: arrivalDeadline,
|
||||||
|
byArrival: true,
|
||||||
|
max: _maxTrips,
|
||||||
|
),
|
||||||
|
_repo.findTrips(
|
||||||
|
from: school,
|
||||||
|
to: home,
|
||||||
|
when: departureEarliest,
|
||||||
|
byArrival: false,
|
||||||
|
max: _maxTrips,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
log(
|
||||||
|
'commute $key result: ${results[0].length} morning, '
|
||||||
|
'${results[1].length} evening',
|
||||||
|
);
|
||||||
|
_set(
|
||||||
|
key,
|
||||||
|
(e) => e.copyWith(
|
||||||
|
morning: results[0],
|
||||||
|
evening: results[1],
|
||||||
|
loading: false,
|
||||||
|
loadedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
log('commute $key load failed: $e', stackTrace: st);
|
||||||
|
_set(
|
||||||
|
key,
|
||||||
|
(e2) => e2.copyWith(
|
||||||
|
loading: false,
|
||||||
|
errorMessage: errorToUserMessage(e),
|
||||||
|
loadedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _set(String key, CommuteDayEntry Function(CommuteDayEntry) update) {
|
||||||
|
final next = Map<String, CommuteDayEntry>.from(state);
|
||||||
|
next[key] = update(next[key] ?? const CommuteDayEntry());
|
||||||
|
emit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reset() async {
|
||||||
|
_inflight.clear();
|
||||||
|
emit(const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import '../../../../../api/connect/rmv/queries/search_stops.dart';
|
||||||
|
import '../../../../../api/connect/rmv/rmv_models.dart';
|
||||||
|
import '../../../../../state/app/modules/rmv/repository/rmv_repository.dart';
|
||||||
|
|
||||||
|
class CommuteRepository {
|
||||||
|
final RmvRepository _rmv = RmvRepository();
|
||||||
|
|
||||||
|
/// Trip search wrapper. Morning runs use `byArrival=true` with the latest
|
||||||
|
/// acceptable arrival time; evening runs use `byArrival=false` with the
|
||||||
|
/// earliest acceptable departure time.
|
||||||
|
Future<List<Trip>> findTrips({
|
||||||
|
required StopLocation from,
|
||||||
|
required StopLocation to,
|
||||||
|
required DateTime when,
|
||||||
|
required bool byArrival,
|
||||||
|
int max = 3,
|
||||||
|
}) async {
|
||||||
|
final result = await _rmv.searchTrips(
|
||||||
|
fromStopId: from.id,
|
||||||
|
toStopId: to.id,
|
||||||
|
when: when,
|
||||||
|
searchByArrival: byArrival,
|
||||||
|
);
|
||||||
|
if (result.trips.length <= max) return result.trips;
|
||||||
|
return result.trips.sublist(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort default school station lookup. Used the first time the user
|
||||||
|
/// activates the commute toggle, before they've had a chance to pick one
|
||||||
|
/// manually. Returns the first hit whose name contains "Marianum", or
|
||||||
|
/// just the first hit, or `null` if the search returned nothing.
|
||||||
|
Future<StopLocation?> resolveDefaultSchoolStation() async {
|
||||||
|
final results = await SearchStops(query: 'Marianum Fulda', max: 5).run();
|
||||||
|
if (results.isEmpty) return null;
|
||||||
|
final marianum = results.where(
|
||||||
|
(s) => s.name.toLowerCase().contains('marianum'),
|
||||||
|
);
|
||||||
|
return marianum.isNotEmpty ? marianum.first : results.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../../../view/pages/timetable/data/timetable_name_mode.dart';
|
import '../api/connect/rmv/rmv_models.dart';
|
||||||
|
import '../view/pages/timetable/data/timetable_name_mode.dart';
|
||||||
|
|
||||||
part 'timetable_settings.g.dart';
|
part 'timetable_settings.g.dart';
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable(explicitToJson: true)
|
||||||
class TimetableSettings {
|
class TimetableSettings {
|
||||||
bool connectDoubleLessons;
|
bool connectDoubleLessons;
|
||||||
TimetableNameMode timetableNameMode;
|
TimetableNameMode timetableNameMode;
|
||||||
|
|
||||||
|
/// Show RMV transit cards before the first lesson and after the last lesson
|
||||||
|
/// of each day.
|
||||||
|
bool showCommuteInTimetable;
|
||||||
|
|
||||||
|
/// The user's home RMV stop. Resolved via Nominatim → nearbyStops in the
|
||||||
|
/// settings flow; once stored it is used directly for all trip lookups.
|
||||||
|
StopLocation? homeStation;
|
||||||
|
|
||||||
|
/// Free-text label of the address the user entered (for display purposes
|
||||||
|
/// only — the actual lookup uses [homeStation]).
|
||||||
|
String? homeAddressLabel;
|
||||||
|
|
||||||
|
/// School-side stop. Default-resolved via `searchStops("Fulda Marianum")`
|
||||||
|
/// on the first activation of [showCommuteInTimetable].
|
||||||
|
StopLocation? schoolStation;
|
||||||
|
|
||||||
|
/// Minutes added as a walking buffer between the stop and the first lesson
|
||||||
|
/// (and analogously after the last lesson).
|
||||||
|
int commuteBufferMinutes;
|
||||||
|
|
||||||
TimetableSettings({
|
TimetableSettings({
|
||||||
required this.connectDoubleLessons,
|
required this.connectDoubleLessons,
|
||||||
required this.timetableNameMode,
|
required this.timetableNameMode,
|
||||||
|
this.showCommuteInTimetable = false,
|
||||||
|
this.homeStation,
|
||||||
|
this.homeAddressLabel,
|
||||||
|
this.schoolStation,
|
||||||
|
this.commuteBufferMinutes = 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TimetableSettings.fromJson(Map<String, dynamic> json) =>
|
factory TimetableSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -6,13 +6,23 @@ part of 'timetable_settings.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
TimetableSettings _$TimetableSettingsFromJson(Map<String, dynamic> json) =>
|
TimetableSettings _$TimetableSettingsFromJson(
|
||||||
TimetableSettings(
|
Map<String, dynamic> json,
|
||||||
|
) => TimetableSettings(
|
||||||
connectDoubleLessons: json['connectDoubleLessons'] as bool,
|
connectDoubleLessons: json['connectDoubleLessons'] as bool,
|
||||||
timetableNameMode: $enumDecode(
|
timetableNameMode: $enumDecode(
|
||||||
_$TimetableNameModeEnumMap,
|
_$TimetableNameModeEnumMap,
|
||||||
json['timetableNameMode'],
|
json['timetableNameMode'],
|
||||||
),
|
),
|
||||||
|
showCommuteInTimetable: json['showCommuteInTimetable'] as bool? ?? false,
|
||||||
|
homeStation: json['homeStation'] == null
|
||||||
|
? null
|
||||||
|
: StopLocation.fromJson(json['homeStation'] as Map<String, dynamic>),
|
||||||
|
homeAddressLabel: json['homeAddressLabel'] as String?,
|
||||||
|
schoolStation: json['schoolStation'] == null
|
||||||
|
? null
|
||||||
|
: StopLocation.fromJson(json['schoolStation'] as Map<String, dynamic>),
|
||||||
|
commuteBufferMinutes: (json['commuteBufferMinutes'] as num?)?.toInt() ?? 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TimetableSettingsToJson(
|
Map<String, dynamic> _$TimetableSettingsToJson(
|
||||||
@@ -20,6 +30,11 @@ Map<String, dynamic> _$TimetableSettingsToJson(
|
|||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'connectDoubleLessons': instance.connectDoubleLessons,
|
'connectDoubleLessons': instance.connectDoubleLessons,
|
||||||
'timetableNameMode': _$TimetableNameModeEnumMap[instance.timetableNameMode]!,
|
'timetableNameMode': _$TimetableNameModeEnumMap[instance.timetableNameMode]!,
|
||||||
|
'showCommuteInTimetable': instance.showCommuteInTimetable,
|
||||||
|
'homeStation': instance.homeStation?.toJson(),
|
||||||
|
'homeAddressLabel': instance.homeAddressLabel,
|
||||||
|
'schoolStation': instance.schoolStation?.toJson(),
|
||||||
|
'commuteBufferMinutes': instance.commuteBufferMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$TimetableNameModeEnumMap = {
|
const _$TimetableNameModeEnumMap = {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
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 '../../../../api/geocoding/nominatim_result.dart';
|
||||||
|
import '../../../../api/geocoding/nominatim_search.dart';
|
||||||
|
import '../../../../state/app/modules/commute/repository/commute_repository.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 '../../../../widget/centered_leading.dart';
|
||||||
|
import '../../rmv/widgets/station_picker_sheet.dart';
|
||||||
|
|
||||||
|
/// Settings block for the timetable-commute prototype. Toggle + home address
|
||||||
|
/// flow (Nominatim → nearbyStops) + school station picker + walking buffer.
|
||||||
|
class CommuteSettingsSection extends StatelessWidget {
|
||||||
|
const CommuteSettingsSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final settings = context.watch<SettingsCubit>();
|
||||||
|
final s = settings.val().timetableSettings;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.directions_bus_outlined),
|
||||||
|
title: const Text('Pendel-Verbindung im Stundenplan'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Zeigt für jeden Schultag die ÖPNV-Verbindung von und zur Schule.',
|
||||||
|
),
|
||||||
|
value: s.showCommuteInTimetable,
|
||||||
|
onChanged: (v) => _toggle(context, v),
|
||||||
|
),
|
||||||
|
if (s.showCommuteInTimetable) ...[
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.home_outlined)),
|
||||||
|
title: const Text('Heimat-Haltestelle'),
|
||||||
|
subtitle: Text(_homeSubtitle(s.homeAddressLabel, s.homeStation)),
|
||||||
|
trailing: const Icon(Icons.edit_outlined),
|
||||||
|
onTap: () => _editHome(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
||||||
|
title: const Text('Schul-Haltestelle'),
|
||||||
|
subtitle: Text(s.schoolStation?.name ?? 'Noch nicht gesetzt'),
|
||||||
|
trailing: const Icon(Icons.edit_outlined),
|
||||||
|
onTap: () => _editSchool(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
|
||||||
|
title: const Text('Pufferzeit'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${s.commuteBufferMinutes} Min Fußweg vor/nach dem Schultag',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.edit_outlined),
|
||||||
|
onTap: () => _editBuffer(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _homeSubtitle(String? label, StopLocation? home) {
|
||||||
|
if (home == null) return 'Noch nicht gesetzt';
|
||||||
|
if (label == null || label.isEmpty) return home.name;
|
||||||
|
return '${home.name}\n($label)';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggle(BuildContext context, bool value) async {
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
settings.val(write: true).timetableSettings.showCommuteInTimetable = value;
|
||||||
|
if (!value) return;
|
||||||
|
final current = settings.val().timetableSettings.schoolStation;
|
||||||
|
if (current != null) return;
|
||||||
|
// Best-effort default resolve so the user doesn't have to pick the
|
||||||
|
// school station manually if the RMV knows "Marianum".
|
||||||
|
try {
|
||||||
|
final resolved = await CommuteRepository().resolveDefaultSchoolStation();
|
||||||
|
if (resolved == null || !context.mounted) return;
|
||||||
|
settings.val(write: true).timetableSettings.schoolStation = resolved;
|
||||||
|
} catch (_) {
|
||||||
|
// Silent: settings tile still shows "Noch nicht gesetzt" + edit option.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editSchool(BuildContext context) async {
|
||||||
|
final picked = await showStationPickerSheet(
|
||||||
|
context,
|
||||||
|
title: 'Schul-Haltestelle wählen',
|
||||||
|
);
|
||||||
|
if (picked == null || !context.mounted) return;
|
||||||
|
context.read<SettingsCubit>().val(write: true).timetableSettings.schoolStation = picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editBuffer(BuildContext context) async {
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
final current = settings.val().timetableSettings.commuteBufferMinutes;
|
||||||
|
final picked = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _BufferPickerDialog(initial: current),
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
settings.val(write: true).timetableSettings.commuteBufferMinutes = picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editHome(BuildContext context) async {
|
||||||
|
final picked = await showModalBottomSheet<_HomeSelection>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => const _HomeAddressFlow(),
|
||||||
|
);
|
||||||
|
if (picked == null || !context.mounted) return;
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
settings.val(write: true).timetableSettings
|
||||||
|
..homeAddressLabel = picked.addressLabel
|
||||||
|
..homeStation = picked.stop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeSelection {
|
||||||
|
final String addressLabel;
|
||||||
|
final StopLocation stop;
|
||||||
|
const _HomeSelection({required this.addressLabel, required this.stop});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BufferPickerDialog extends StatefulWidget {
|
||||||
|
final int initial;
|
||||||
|
const _BufferPickerDialog({required this.initial});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BufferPickerDialog> createState() => _BufferPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BufferPickerDialogState extends State<_BufferPickerDialog> {
|
||||||
|
late double _value = widget.initial.toDouble();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AlertDialog(
|
||||||
|
title: const Text('Pufferzeit'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${_value.round()} Minuten Fußweg',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
min: 0,
|
||||||
|
max: 30,
|
||||||
|
divisions: 30,
|
||||||
|
value: _value,
|
||||||
|
label: '${_value.round()} min',
|
||||||
|
onChanged: (v) => setState(() => _value = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(_value.round()),
|
||||||
|
child: const Text('Übernehmen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-step picker: address search via Nominatim → choose address →
|
||||||
|
/// nearbyStops → choose stop. Returns the chosen [_HomeSelection] via
|
||||||
|
/// Navigator.pop, or null if dismissed.
|
||||||
|
class _HomeAddressFlow extends StatefulWidget {
|
||||||
|
const _HomeAddressFlow();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_HomeAddressFlow> createState() => _HomeAddressFlowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeAddressFlowState extends State<_HomeAddressFlow> {
|
||||||
|
final _queryCtrl = TextEditingController();
|
||||||
|
final NominatimSearch _geo = NominatimSearch();
|
||||||
|
final RmvRepository _rmv = RmvRepository();
|
||||||
|
|
||||||
|
List<NominatimResult>? _addresses;
|
||||||
|
List<StopLocation>? _stops;
|
||||||
|
NominatimResult? _chosenAddress;
|
||||||
|
bool _loading = false;
|
||||||
|
Object? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_queryCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _searchAddress() async {
|
||||||
|
final q = _queryCtrl.text.trim();
|
||||||
|
if (q.length < 3) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_stops = null;
|
||||||
|
_chosenAddress = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final res = await _geo.run(q, limit: 5);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_addresses = res;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAddress(NominatimResult addr) async {
|
||||||
|
setState(() {
|
||||||
|
_chosenAddress = addr;
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final stops = await _rmv.nearbyStops(
|
||||||
|
lat: addr.lat,
|
||||||
|
lon: addr.lon,
|
||||||
|
radiusMeters: 800,
|
||||||
|
max: 8,
|
||||||
|
);
|
||||||
|
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(() {
|
||||||
|
_error = e;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickStop(StopLocation stop) {
|
||||||
|
final addr = _chosenAddress;
|
||||||
|
if (addr == null) return;
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
_HomeSelection(addressLabel: addr.displayName, stop: stop),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
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(
|
||||||
|
'Heimadresse einrichten',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Adresse wird zur Suche an OpenStreetMap (Nominatim) übermittelt.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _queryCtrl,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onSubmitted: (_) => _searchAddress(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Straße, Hausnr., Ort',
|
||||||
|
prefixIcon: const Icon(Icons.home_outlined),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loading ? null : _searchAddress,
|
||||||
|
child: const Text('Suchen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.55,
|
||||||
|
minHeight: 180,
|
||||||
|
),
|
||||||
|
child: _body(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body() {
|
||||||
|
if (_loading) return const Center(child: AppProgressIndicator.medium());
|
||||||
|
if (_error != null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
errorToUserMessage(_error),
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final stops = _stops;
|
||||||
|
if (stops != null) return _stopsList(stops);
|
||||||
|
final addresses = _addresses;
|
||||||
|
if (addresses != null) return _addressList(addresses);
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Gib deine Adresse ein und tippe "Suchen", um die nächstgelegenen RMV-Haltestellen zu finden.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _addressList(List<NominatimResult> addresses) {
|
||||||
|
if (addresses.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('Keine Adresse zur Suche gefunden.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: addresses.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) => ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.place_outlined)),
|
||||||
|
title: Text(addresses[i].displayName),
|
||||||
|
onTap: () => _pickAddress(addresses[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stopsList(List<StopLocation> stops) {
|
||||||
|
if (stops.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('Keine Haltestelle in 800 m Umkreis gefunden.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: stops.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final s = stops[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.directions_transit)),
|
||||||
|
title: Text(s.name),
|
||||||
|
subtitle: s.distanceMeters == null
|
||||||
|
? null
|
||||||
|
: Text('${s.distanceMeters} m'),
|
||||||
|
onTap: () => _pickStop(s),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||||
|
import 'commute_settings_section.dart';
|
||||||
|
|
||||||
class TimetableSection extends StatelessWidget {
|
class TimetableSection extends StatelessWidget {
|
||||||
const TimetableSection({super.key});
|
const TimetableSection({super.key});
|
||||||
@@ -54,6 +55,8 @@ class TimetableSection extends StatelessWidget {
|
|||||||
e!,
|
e!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const CommuteSettingsSection(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
|
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
|
||||||
|
import 'commute_direction.dart';
|
||||||
|
|
||||||
sealed class ArbitraryAppointment {
|
sealed class ArbitraryAppointment {
|
||||||
const ArbitraryAppointment();
|
const ArbitraryAppointment();
|
||||||
@@ -7,9 +9,12 @@ sealed class ArbitraryAppointment {
|
|||||||
T when<T>({
|
T when<T>({
|
||||||
required T Function(GetTimetableResponseObject lesson) webuntis,
|
required T Function(GetTimetableResponseObject lesson) webuntis,
|
||||||
required T Function(CustomTimetableEvent event) custom,
|
required T Function(CustomTimetableEvent event) custom,
|
||||||
|
required T Function(Trip trip, CommuteDirection direction) commute,
|
||||||
}) => switch (this) {
|
}) => switch (this) {
|
||||||
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
||||||
CustomAppointment(:final event) => custom(event),
|
CustomAppointment(:final event) => custom(event),
|
||||||
|
CommuteAppointment(:final trip, :final direction) =>
|
||||||
|
commute(trip, direction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,3 +27,9 @@ class CustomAppointment extends ArbitraryAppointment {
|
|||||||
final CustomTimetableEvent event;
|
final CustomTimetableEvent event;
|
||||||
const CustomAppointment(this.event);
|
const CustomAppointment(this.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CommuteAppointment extends ArbitraryAppointment {
|
||||||
|
final Trip trip;
|
||||||
|
final CommuteDirection direction;
|
||||||
|
const CommuteAppointment(this.trip, this.direction);
|
||||||
|
}
|
||||||
|
|||||||
@@ -282,8 +282,9 @@ class LaidOutOverflow extends LaidOutCell {
|
|||||||
int _appointmentPriority(Appointment a) {
|
int _appointmentPriority(Appointment a) {
|
||||||
final id = a.id;
|
final id = a.id;
|
||||||
if (id is CustomAppointment) return 0;
|
if (id is CustomAppointment) return 0;
|
||||||
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
|
if (id is CommuteAppointment) return 1;
|
||||||
return 2;
|
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 2;
|
||||||
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assigns each appointment a lane index using a greedy sweep, then collapses
|
/// Assigns each appointment a lane index using a greedy sweep, then collapses
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
|
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import 'arbitrary_appointment.dart';
|
||||||
|
import 'commute_direction.dart';
|
||||||
|
|
||||||
|
/// Builds [Appointment] objects from RMV [Trip]s so the existing timetable
|
||||||
|
/// lane layout can render them next to school lessons. Per-trip cancellation
|
||||||
|
/// flips the color to red; the [appointment.id] always wraps the original
|
||||||
|
/// [Trip] so the tap handler can surface its details.
|
||||||
|
class CommuteAppointmentFactory {
|
||||||
|
static const Color colorMorning = Color(0xFFFB8C00); // amber 600
|
||||||
|
static const Color colorEvening = Color(0xFF8E24AA); // purple 600
|
||||||
|
static const Color colorCancelled = Color(0xFFE53935); // red 600
|
||||||
|
|
||||||
|
/// Converts every entry in [morning]/[evening] into an Appointment.
|
||||||
|
static List<Appointment> build({
|
||||||
|
required List<Trip> morning,
|
||||||
|
required List<Trip> evening,
|
||||||
|
}) => [
|
||||||
|
for (final trip in morning) ?_tripToAppointment(trip, CommuteDirection.toSchool),
|
||||||
|
for (final trip in evening) ?_tripToAppointment(trip, CommuteDirection.fromSchool),
|
||||||
|
];
|
||||||
|
|
||||||
|
static Appointment? _tripToAppointment(Trip trip, CommuteDirection direction) {
|
||||||
|
final firstLeg = trip.legs.firstOrNull;
|
||||||
|
final lastLeg = trip.legs.lastOrNull;
|
||||||
|
if (firstLeg == null || lastLeg == null) return null;
|
||||||
|
final start = firstLeg.origin.scheduledTime;
|
||||||
|
final end = lastLeg.destination.scheduledTime;
|
||||||
|
if (!end.isAfter(start)) return null;
|
||||||
|
|
||||||
|
final cancelled =
|
||||||
|
trip.legs.every((l) => l.cancelled || l.partCancelled) &&
|
||||||
|
trip.legs.isNotEmpty;
|
||||||
|
final color = cancelled
|
||||||
|
? colorCancelled
|
||||||
|
: (direction == CommuteDirection.toSchool
|
||||||
|
? colorMorning
|
||||||
|
: colorEvening);
|
||||||
|
|
||||||
|
return Appointment(
|
||||||
|
id: CommuteAppointment(trip, direction),
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
subject: _subject(trip),
|
||||||
|
location: _location(direction, start, end),
|
||||||
|
color: color,
|
||||||
|
startTimeZone: '',
|
||||||
|
endTimeZone: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _subject(Trip trip) {
|
||||||
|
final lines = trip.legs
|
||||||
|
.where((l) => l.type == LegType.journey)
|
||||||
|
.map(_legLabel)
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (lines.isEmpty) return 'Fußweg';
|
||||||
|
return lines.join(' › ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _legLabel(Leg leg) {
|
||||||
|
final p = leg.product;
|
||||||
|
if (p == null) return leg.name ?? '?';
|
||||||
|
if (p.line != null && p.line!.isNotEmpty) return p.line!;
|
||||||
|
if (p.displayNumber != null && p.displayNumber!.isNotEmpty) {
|
||||||
|
return '${p.category ?? ''}${p.displayNumber}'.trim();
|
||||||
|
}
|
||||||
|
return p.name ?? leg.name ?? '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _location(
|
||||||
|
CommuteDirection direction,
|
||||||
|
DateTime start,
|
||||||
|
DateTime end,
|
||||||
|
) {
|
||||||
|
final label = direction == CommuteDirection.toSchool ? 'Hinfahrt' : 'Heimfahrt';
|
||||||
|
return '$label\n${start.formatHm()}–${end.formatHm()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/// Direction of a commute trip relative to the school day.
|
||||||
|
enum CommuteDirection { toSchool, fromSchool }
|
||||||
@@ -3,6 +3,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
|
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../data/arbitrary_appointment.dart';
|
import '../data/arbitrary_appointment.dart';
|
||||||
|
import '../widgets/commute/commute_details_sheet.dart';
|
||||||
import 'custom_event_sheet.dart';
|
import 'custom_event_sheet.dart';
|
||||||
import 'webuntis_lesson_sheet.dart';
|
import 'webuntis_lesson_sheet.dart';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ class AppointmentDetailsDispatcher {
|
|||||||
webuntis: (lesson) =>
|
webuntis: (lesson) =>
|
||||||
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
||||||
custom: (event) => CustomEventSheet.show(context, event),
|
custom: (event) => CustomEventSheet.show(context, event),
|
||||||
|
commute: (trip, direction) =>
|
||||||
|
showCommuteDetailsSheet(context, trip: trip, direction: direction),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
|
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
|
import '../../../state/app/modules/commute/bloc/commute_cubit.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../../../storage/timetable_settings.dart';
|
import '../../../storage/timetable_settings.dart';
|
||||||
import 'custom_events/custom_event_edit_dialog.dart';
|
import 'custom_events/custom_event_edit_dialog.dart';
|
||||||
import 'data/arbitrary_appointment.dart';
|
import 'data/arbitrary_appointment.dart';
|
||||||
|
import 'data/commute_appointment_factory.dart';
|
||||||
import 'data/lesson_period_schedule.dart';
|
import 'data/lesson_period_schedule.dart';
|
||||||
import 'data/timetable_appointment_factory.dart';
|
import 'data/timetable_appointment_factory.dart';
|
||||||
import 'data/webuntis_time.dart';
|
import 'data/webuntis_time.dart';
|
||||||
@@ -33,6 +35,7 @@ class _TimetableState extends State<Timetable> {
|
|||||||
List<Appointment>? _cachedAppointments;
|
List<Appointment>? _cachedAppointments;
|
||||||
int? _lastDataVersion;
|
int? _lastDataVersion;
|
||||||
TimetableSettings? _lastTimetableSettings;
|
TimetableSettings? _lastTimetableSettings;
|
||||||
|
Map<String, CommuteDayEntry>? _lastCommuteState;
|
||||||
|
|
||||||
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
||||||
|
|
||||||
@@ -58,15 +61,23 @@ class _TimetableState extends State<Timetable> {
|
|||||||
.watch<SettingsCubit>()
|
.watch<SettingsCubit>()
|
||||||
.val()
|
.val()
|
||||||
.timetableSettings;
|
.timetableSettings;
|
||||||
|
final commuteState = context.watch<CommuteCubit>().state;
|
||||||
|
|
||||||
|
// Kick off any missing commute fetches for the currently visible weeks.
|
||||||
|
// The cubit's ttl/inflight guards make this safe to call on every build.
|
||||||
|
_maybeRequestCommute(state, timetableSettings);
|
||||||
|
|
||||||
if (_cachedAppointments != null &&
|
if (_cachedAppointments != null &&
|
||||||
_lastDataVersion == state.dataVersion &&
|
_lastDataVersion == state.dataVersion &&
|
||||||
identical(_lastTimetableSettings, timetableSettings)) {
|
identical(_lastTimetableSettings, timetableSettings) &&
|
||||||
|
identical(_lastCommuteState, commuteState)) {
|
||||||
return _cachedAppointments!;
|
return _cachedAppointments!;
|
||||||
}
|
}
|
||||||
_lastDataVersion = state.dataVersion;
|
_lastDataVersion = state.dataVersion;
|
||||||
_lastTimetableSettings = timetableSettings;
|
_lastTimetableSettings = timetableSettings;
|
||||||
|
_lastCommuteState = commuteState;
|
||||||
|
|
||||||
return _cachedAppointments = TimetableAppointmentFactory(
|
final base = TimetableAppointmentFactory(
|
||||||
lessons: state.getAllKnownLessons().toList(),
|
lessons: state.getAllKnownLessons().toList(),
|
||||||
customEvents: state.customEvents?.events ?? const [],
|
customEvents: state.customEvents?.events ?? const [],
|
||||||
rooms: state.rooms!,
|
rooms: state.rooms!,
|
||||||
@@ -74,11 +85,74 @@ class _TimetableState extends State<Timetable> {
|
|||||||
settings: timetableSettings,
|
settings: timetableSettings,
|
||||||
now: DateTime.now(),
|
now: DateTime.now(),
|
||||||
).build();
|
).build();
|
||||||
|
|
||||||
|
if (!timetableSettings.showCommuteInTimetable || commuteState.isEmpty) {
|
||||||
|
return _cachedAppointments = base;
|
||||||
|
}
|
||||||
|
|
||||||
|
final commute = <Appointment>[];
|
||||||
|
for (final entry in commuteState.values) {
|
||||||
|
commute.addAll(
|
||||||
|
CommuteAppointmentFactory.build(
|
||||||
|
morning: entry.morning,
|
||||||
|
evening: entry.evening,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _cachedAppointments = [...base, ...commute];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeRequestCommute(
|
||||||
|
TimetableState state,
|
||||||
|
TimetableSettings timetableSettings,
|
||||||
|
) {
|
||||||
|
if (!timetableSettings.showCommuteInTimetable) return;
|
||||||
|
if (timetableSettings.homeStation == null) return;
|
||||||
|
if (timetableSettings.schoolStation == null) return;
|
||||||
|
|
||||||
|
final spans = _lessonSpansByDay(state);
|
||||||
|
if (spans.isEmpty) return;
|
||||||
|
|
||||||
|
context.read<CommuteCubit>().ensureLoaded(
|
||||||
|
lessonsByDay: spans,
|
||||||
|
settings: timetableSettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<DateTime, LessonSpan> _lessonSpansByDay(TimetableState state) {
|
||||||
|
final byDay = <DateTime, _MinMax>{};
|
||||||
|
for (final lesson in state.getAllKnownLessons()) {
|
||||||
|
try {
|
||||||
|
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
|
||||||
|
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
|
||||||
|
final day = DateTime(start.year, start.month, start.day);
|
||||||
|
final existing = byDay[day];
|
||||||
|
if (existing == null) {
|
||||||
|
byDay[day] = _MinMax(start, end);
|
||||||
|
} else {
|
||||||
|
if (start.isBefore(existing.min)) existing.min = start;
|
||||||
|
if (end.isAfter(existing.max)) existing.max = end;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Skip lessons we can't parse — same fallback as elsewhere.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
for (final entry in byDay.entries)
|
||||||
|
entry.key: LessonSpan(entry.value.min, entry.value.max),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isCrossedOut(Appointment appointment) {
|
bool _isCrossedOut(Appointment appointment) {
|
||||||
final id = appointment.id;
|
final id = appointment.id;
|
||||||
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
||||||
|
if (id is CommuteAppointment) {
|
||||||
|
// Strike the tile only if literally every leg is cancelled — partially
|
||||||
|
// cancelled trips still get the user somewhere and should stay legible.
|
||||||
|
final legs = id.trip.legs;
|
||||||
|
return legs.isNotEmpty &&
|
||||||
|
legs.every((l) => l.cancelled || l.partCancelled);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,3 +291,9 @@ class _TimetableState extends State<Timetable> {
|
|||||||
return (mondayMin, effectiveMax);
|
return (mondayMin, effectiveMax);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MinMax {
|
||||||
|
DateTime min;
|
||||||
|
DateTime max;
|
||||||
|
_MinMax(this.min, this.max);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
|
|
||||||
import '../data/arbitrary_appointment.dart';
|
import '../data/arbitrary_appointment.dart';
|
||||||
import '../data/calendar_layout.dart';
|
import '../data/calendar_layout.dart';
|
||||||
|
import 'commute/commute_tile_content.dart';
|
||||||
import 'cross_painter.dart';
|
import 'cross_painter.dart';
|
||||||
|
|
||||||
class AppointmentTile extends StatelessWidget {
|
class AppointmentTile extends StatelessWidget {
|
||||||
@@ -21,7 +22,9 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||||
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
||||||
final isCustom = appointment.id is CustomAppointment;
|
final id = appointment.id;
|
||||||
|
final isCustom = id is CustomAppointment;
|
||||||
|
final isCommute = id is CommuteAppointment;
|
||||||
final description = appointment.location ?? '';
|
final description = appointment.location ?? '';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -37,7 +40,9 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
borderRadius: _radius,
|
borderRadius: _radius,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
child: _TileContent(
|
child: isCommute
|
||||||
|
? CommuteTileContent(commute: id, crossedOut: crossedOut)
|
||||||
|
: _TileContent(
|
||||||
title: appointment.subject,
|
title: appointment.subject,
|
||||||
description: description,
|
description: description,
|
||||||
isCustom: isCustom,
|
isCustom: isCustom,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/connect/rmv/rmv_models.dart';
|
||||||
|
import '../../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../../widget/details_bottom_sheet.dart';
|
||||||
|
import '../../../rmv/widgets/leg_tile.dart';
|
||||||
|
import '../../../rmv/widgets/realtime_time.dart';
|
||||||
|
import '../../data/commute_direction.dart';
|
||||||
|
|
||||||
|
/// Reuses the RMV-module LegTile so the in-timetable trip detail looks
|
||||||
|
/// identical to the regular trip-details view in the RMV module.
|
||||||
|
void showCommuteDetailsSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required Trip trip,
|
||||||
|
required CommuteDirection direction,
|
||||||
|
}) {
|
||||||
|
final firstLeg = trip.legs.firstOrNull;
|
||||||
|
final lastLeg = trip.legs.lastOrNull;
|
||||||
|
|
||||||
|
showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
header: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
direction == CommuteDirection.toSchool
|
||||||
|
? 'Hinfahrt zur Schule'
|
||||||
|
: 'Heimfahrt',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (firstLeg != null && lastLeg != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${firstLeg.origin.name} → ${lastLeg.destination.name}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RealtimeTime(
|
||||||
|
scheduled: firstLeg.origin.scheduledTime,
|
||||||
|
realtime: firstLeg.origin.realTime,
|
||||||
|
delayMinutes: firstLeg.origin.delayMinutes?.toInt(),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Text('–'),
|
||||||
|
),
|
||||||
|
RealtimeTime(
|
||||||
|
scheduled: lastLeg.destination.scheduledTime,
|
||||||
|
realtime: lastLeg.destination.realTime,
|
||||||
|
delayMinutes: lastLeg.destination.delayMinutes?.toInt(),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (trip.realDuration != null || trip.duration != null)
|
||||||
|
Text(
|
||||||
|
_formatDuration(trip.realDuration ?? trip.duration!),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: (sheetCtx) => [
|
||||||
|
...trip.legs.map(
|
||||||
|
(l) => LegTile(
|
||||||
|
leg: l,
|
||||||
|
onShowJourneyDetail: l.journeyRef == null
|
||||||
|
? null
|
||||||
|
: () => AppRoutes.openRmvJourneyDetail(
|
||||||
|
sheetCtx,
|
||||||
|
l.journeyRef!,
|
||||||
|
date: l.origin.scheduledTime,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration d) {
|
||||||
|
final h = d.inHours;
|
||||||
|
final m = d.inMinutes.remainder(60);
|
||||||
|
return h == 0 ? '$m min' : '$h h ${m.toString().padLeft(2, '0')} min';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used in headers to label the trip start date relative to today.
|
||||||
|
String formatCommuteDay(DateTime day) => day.formatDateRelativeShort();
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/connect/rmv/rmv_models.dart';
|
||||||
|
import '../../../../../extensions/date_time.dart';
|
||||||
|
import '../../data/arbitrary_appointment.dart';
|
||||||
|
import '../../data/commute_direction.dart';
|
||||||
|
|
||||||
|
/// Tile body for [CommuteAppointment]s: bus icon + line label up top, real-time
|
||||||
|
/// departure with delay marker below. Designed to stay readable in 60–80 px
|
||||||
|
/// tall lanes — collapses gracefully when the lane is shorter.
|
||||||
|
class CommuteTileContent extends StatelessWidget {
|
||||||
|
final CommuteAppointment commute;
|
||||||
|
final bool crossedOut;
|
||||||
|
|
||||||
|
const CommuteTileContent({
|
||||||
|
super.key,
|
||||||
|
required this.commute,
|
||||||
|
this.crossedOut = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final trip = commute.trip;
|
||||||
|
final firstLeg = trip.legs.firstOrNull;
|
||||||
|
if (firstLeg == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final scheduled = firstLeg.origin.scheduledTime;
|
||||||
|
final realtime = firstLeg.origin.realTime;
|
||||||
|
final delay = firstLeg.origin.delayMinutes?.toInt();
|
||||||
|
final lineLabel = _lineLabel(trip);
|
||||||
|
final track = (firstLeg.origin.realTrack?.isNotEmpty ?? false)
|
||||||
|
? firstLeg.origin.realTrack
|
||||||
|
: firstLeg.origin.track;
|
||||||
|
final cancelled = crossedOut;
|
||||||
|
|
||||||
|
final dirIcon = commute.direction == CommuteDirection.toSchool
|
||||||
|
? Icons.school_outlined
|
||||||
|
: Icons.home_outlined;
|
||||||
|
final dirLabel = commute.direction == CommuteDirection.toSchool
|
||||||
|
? '→ Schule'
|
||||||
|
: '→ Heimat';
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final h = constraints.maxHeight;
|
||||||
|
if (h < 14) return const SizedBox.shrink();
|
||||||
|
final children = <Widget>[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(dirIcon, size: 12, color: Colors.white),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$lineLabel $dirLabel',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.1,
|
||||||
|
decoration:
|
||||||
|
cancelled ? TextDecoration.lineThrough : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (h >= 28) {
|
||||||
|
children.add(const SizedBox(height: 1));
|
||||||
|
children.add(_timeRow(scheduled, realtime, delay, cancelled));
|
||||||
|
}
|
||||||
|
if (h >= 42 && track != null && track.isNotEmpty) {
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 1),
|
||||||
|
child: Text(
|
||||||
|
'Gleis $track',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 9, height: 1.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _timeRow(
|
||||||
|
DateTime scheduled,
|
||||||
|
DateTime? realtime,
|
||||||
|
int? delay,
|
||||||
|
bool cancelled,
|
||||||
|
) {
|
||||||
|
final baseStyle = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.1,
|
||||||
|
decoration: cancelled ? TextDecoration.lineThrough : null,
|
||||||
|
);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(scheduled.formatHm(), style: baseStyle),
|
||||||
|
if (delay != null && delay != 0) ...[
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'${delay > 0 ? '+' : ''}$delay\'',
|
||||||
|
style: baseStyle.copyWith(
|
||||||
|
color: delay > 0 ? const Color(0xFFFFCDD2) : const Color(0xFFC8E6C9),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
decoration: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _lineLabel(Trip trip) {
|
||||||
|
final journeys = trip.legs.where((l) => l.type == LegType.journey).toList();
|
||||||
|
if (journeys.isEmpty) return 'Fußweg';
|
||||||
|
return journeys.map(_legLabel).join(' › ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _legLabel(Leg leg) {
|
||||||
|
final p = leg.product;
|
||||||
|
if (p == null) return leg.name ?? '?';
|
||||||
|
if (p.line != null && p.line!.isNotEmpty) return p.line!;
|
||||||
|
if (p.displayNumber != null && p.displayNumber!.isNotEmpty) {
|
||||||
|
return '${p.category ?? ''}${p.displayNumber}'.trim();
|
||||||
|
}
|
||||||
|
return p.name ?? leg.name ?? '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user