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

This commit is contained in:
2026-05-20 19:08:05 +02:00
parent f185b3273a
commit 067012cc84
61 changed files with 7885 additions and 1 deletions
+179
View File
@@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../api_request.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import 'connect_auth_store.dart';
import 'connect_endpoint.dart';
import 'errors/connect_exception.dart';
import 'errors/rmv_rate_limited_exception.dart';
import 'errors/rmv_upstream_exception.dart';
enum ConnectHttpMethod { get, post }
/// Mirrors the [MhslApi] pattern: each endpoint subclasses this, declares the
/// subpath/query/body, and implements [assemble]. Handles bearer-token
/// injection (via [ConnectAuthStore]), one transparent 401-retry after a
/// fresh login, and turns the structured `RmvController.wrap` error strings
/// into typed exceptions.
abstract class ConnectApi<T> extends ApiRequest {
final String subpath;
ConnectApi(this.subpath);
/// Override to `false` for endpoints that must NOT receive a bearer token
/// (currently only login itself, to avoid an infinite refresh loop).
bool get requiresAuth => true;
ConnectHttpMethod get method => ConnectHttpMethod.get;
Map<String, String>? get queryParameters => null;
/// Returns the body to send for POST requests. Should be JSON-encodable.
Object? get body => null;
T assemble(String raw);
Future<T> run() async {
final response = await _runOnce(forceTokenRefresh: false);
if (response.statusCode == 401 && requiresAuth) {
// Single transparent retry after a forced refresh, then bail.
await ConnectAuthStore.instance.invalidate();
final retry = await _runOnce(forceTokenRefresh: true);
if (retry.statusCode == 401) {
throw ConnectException.authFailed(
technicalDetails:
'connect $subpath HTTP 401 after token refresh: ${_safeBody(retry)}',
);
}
return _handleResponse(retry);
}
return _handleResponse(response);
}
Future<http.Response> _runOnce({required bool forceTokenRefresh}) async {
final uri = ConnectEndpoint.resolve(subpath).replace(
queryParameters: _normaliseQuery(queryParameters),
);
final headers = <String, String>{
if (method == ConnectHttpMethod.post) 'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (requiresAuth) {
final token = await ConnectAuthStore.instance.getToken(
forceRefresh: forceTokenRefresh,
);
headers['Authorization'] = 'Bearer $token';
}
try {
switch (method) {
case ConnectHttpMethod.get:
return await http.get(uri, headers: headers);
case ConnectHttpMethod.post:
final payload = body;
return await http.post(
uri,
headers: headers,
body: payload == null ? null : jsonEncode(payload),
);
}
} on SocketException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on TimeoutException catch (e) {
throw NetworkException.timeout(
technicalDetails: 'connect $subpath: $e',
);
} on http.ClientException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on HandshakeException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath TLS: ${e.message}',
);
}
}
T _handleResponse(http.Response response) {
final status = response.statusCode;
final bodyText = _safeBody(response);
if (status == 503) {
final retryAfter = _parseRetryAfter(bodyText);
throw RmvRateLimitedException(
retryAfter: retryAfter,
technicalDetails: 'connect $subpath HTTP 503: $bodyText',
);
}
if (status == 502) {
final code = _parseUpstreamErrorCode(bodyText);
throw RmvUpstreamException(
errorCode: code,
technicalDetails: 'connect $subpath HTTP 502: $bodyText',
);
}
if (status > 299) {
throw ServerException(
statusCode: status,
technicalDetails: 'connect $subpath HTTP $status: $bodyText',
);
}
try {
return assemble(bodyText);
} catch (e, st) {
final preview = bodyText.length > 1024
? '${bodyText.substring(0, 1024)}'
: bodyText;
log(
'connect $subpath assemble failed: $e\nbody: $preview',
stackTrace: st,
);
throw ParseException(
technicalDetails: 'connect $subpath assemble: $e',
);
}
}
String _safeBody(http.Response response) {
try {
return utf8.decode(response.bodyBytes);
} catch (_) {
return response.body;
}
}
/// Body format from `RmvController.wrap`: `upstream_rate_limited|retryAfter=60`.
Duration _parseRetryAfter(String body) {
final match = RegExp(r'retryAfter=(\d+)').firstMatch(body);
final seconds = match == null ? 60 : int.tryParse(match.group(1)!) ?? 60;
return Duration(seconds: seconds);
}
/// Body format: `upstream_error|H390` — the segment after the pipe is the
/// RMV/HaFAS error code.
String? _parseUpstreamErrorCode(String body) {
final idx = body.indexOf('|');
if (idx < 0 || idx >= body.length - 1) return null;
return body.substring(idx + 1).trim();
}
Map<String, String>? _normaliseQuery(Map<String, String>? raw) {
if (raw == null) return null;
final cleaned = <String, String>{};
raw.forEach((key, value) {
if (value.isNotEmpty) cleaned[key] = value;
});
return cleaned.isEmpty ? null : cleaned;
}
}