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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user