Scaffold backend gateway and integration docs

This commit is contained in:
Rijad Zuzo
2026-02-14 20:10:16 +01:00
commit 577c4b33b7
166 changed files with 13382 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Simple in-memory [TokenStore] used in tests and local fakes.
class InMemoryTokenStore implements TokenStore {
AuthSession? _session;
@override
Future<void> clear() async {
_session = null;
}
@override
Future<AuthSession?> read() async => _session;
@override
Future<void> write(AuthSession session) async {
_session = session;
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// [TokenStore] backed by platform secure storage.
class SecureTokenStore implements TokenStore {
SecureTokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const String _sessionKey = 'backend_auth_session_v1';
final FlutterSecureStorage _storage;
@override
Future<AuthSession?> read() async {
final String? raw = await _storage.read(key: _sessionKey);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final Map<String, dynamic> json = jsonDecode(raw) as Map<String, dynamic>;
return AuthSession.fromJson(json);
} on FormatException {
await clear();
return null;
}
}
@override
Future<void> write(AuthSession session) async {
final String raw = jsonEncode(session.toJson());
await _storage.write(key: _sessionKey, value: raw);
}
@override
Future<void> clear() => _storage.delete(key: _sessionKey);
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Session token persistence abstraction.
abstract interface class TokenStore {
/// Reads the current session, if any.
Future<AuthSession?> read();
/// Persists a full auth session.
Future<void> write(AuthSession session);
/// Clears all stored auth state.
Future<void> clear();
}
+29
View File
@@ -0,0 +1,29 @@
/// Application-level runtime configuration.
class AppConfig {
AppConfig._();
static const String _defaultBaseUrl = String.fromEnvironment(
'BACKEND_BASE_URL',
defaultValue: 'https://api.example.com',
);
static String? _baseUrlOverride;
/// Returns the configured backend base URL.
static String get backendBaseUrl {
final String? override = _baseUrlOverride;
if (override != null && override.trim().isNotEmpty) {
return override.trim();
}
return _defaultBaseUrl;
}
/// Enables fake backend implementation for local/offline development.
static bool get useFakeBackend =>
const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: false);
/// Runtime override for backend URL (e.g. local settings screen).
static void overrideBackendBaseUrl(String? baseUrl) {
_baseUrlOverride = baseUrl;
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// Shared app theme, aligned with the visual tone of the reference templates.
class AppTheme {
AppTheme._();
static const Color background = Color(0xFFEDF0F2);
static const Color surface = Color(0xFFFFFFFF);
static const Color primary = Color(0xFF253840);
static const Color accent = Color(0xFF00B6F0);
/// Builds the Material [ThemeData].
static ThemeData light() {
final ColorScheme colorScheme = ColorScheme.fromSeed(
seedColor: primary,
primary: primary,
secondary: accent,
surface: surface,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: background,
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: surface,
foregroundColor: primary,
elevation: 0,
),
);
}
}
+127
View File
@@ -0,0 +1,127 @@
/// Base exception type for backend-facing failures.
sealed class BackendException implements Exception {
const BackendException(
this.message, {
this.statusCode,
this.requestId,
this.backendCode,
});
/// Human-readable message suitable for diagnostics.
final String message;
/// HTTP status code if available.
final int? statusCode;
/// Correlation/request identifier emitted by backend or gateway.
final String? requestId;
/// Structured backend error code if provided by server.
final String? backendCode;
@override
String toString() {
return 'BackendException(message: $message, statusCode: $statusCode, '
'requestId: $requestId, backendCode: $backendCode)';
}
}
/// No HTTP response was received due to connectivity issues.
final class NetworkException extends BackendException {
const NetworkException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Request timed out at connect/send/receive stage.
final class TimeoutException extends BackendException {
const TimeoutException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Backend rejected request due to missing/invalid auth.
class UnauthorizedException extends BackendException {
const UnauthorizedException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Session is no longer valid and refresh failed.
final class AuthExpiredException extends UnauthorizedException {
const AuthExpiredException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Caller is authenticated but lacks permissions.
final class ForbiddenException extends BackendException {
const ForbiddenException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Resource was not found.
final class NotFoundException extends BackendException {
const NotFoundException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Backend rate-limited the request.
final class RateLimitedException extends BackendException {
const RateLimitedException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// 5xx backend failure.
final class ServerException extends BackendException {
const ServerException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Request was syntactically valid but semantically invalid.
final class ValidationException extends BackendException {
const ValidationException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
/// Fallback for failures that do not map to a known category.
final class UnknownBackendException extends BackendException {
const UnknownBackendException(
super.message, {
super.statusCode,
super.requestId,
super.backendCode,
});
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/network/backend_exception.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
/// Maps Dio transport errors into typed [BackendException] failures.
BackendException mapDioException(DioException error) {
final Object? embeddedError = error.error;
if (embeddedError is BackendException) {
return embeddedError;
}
final Response<dynamic>? response = error.response;
final int? statusCode = response?.statusCode;
final Map<String, dynamic>? responseMap = _asMapOrNull(response?.data);
final String? requestId = _extractRequestId(response, responseMap);
final String? backendCode = _extractBackendCode(responseMap);
final String message = _extractMessage(responseMap, error.message);
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
case DioExceptionType.connectionError:
return NetworkException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
case DioExceptionType.badResponse:
if (statusCode == null) {
return UnknownBackendException(
message,
requestId: requestId,
backendCode: backendCode,
);
}
if (statusCode == 400 || statusCode == 422) {
return ValidationException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
if (statusCode == 401) {
return UnauthorizedException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
if (statusCode == 403) {
return ForbiddenException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
if (statusCode == 404) {
return NotFoundException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
if (statusCode == 429) {
return RateLimitedException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
if (statusCode >= 500 && statusCode < 600) {
return ServerException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
return UnknownBackendException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
case DioExceptionType.cancel:
case DioExceptionType.badCertificate:
case DioExceptionType.unknown:
return UnknownBackendException(
message,
statusCode: statusCode,
requestId: requestId,
backendCode: backendCode,
);
}
}
Map<String, dynamic>? _asMapOrNull(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map<Object?, Object?>) {
return value.map<String, dynamic>(
(Object? key, Object? val) => MapEntry(key.toString(), val),
);
}
return null;
}
String? _extractRequestId(
Response<dynamic>? response,
Map<String, dynamic>? responseMap,
) {
final List<String>? fromHeader =
response?.headers.map[NetworkConstants.requestIdHeader.toLowerCase()] ??
response?.headers.map[NetworkConstants.requestIdHeader];
if (fromHeader != null && fromHeader.isNotEmpty) {
return fromHeader.first;
}
final Object? requestId =
responseMap?['requestId'] ?? responseMap?['request_id'];
if (requestId is String && requestId.trim().isNotEmpty) {
return requestId;
}
return null;
}
String? _extractBackendCode(Map<String, dynamic>? responseMap) {
final Object? directCode = responseMap?['code'];
if (directCode is String) {
return directCode;
}
final Map<String, dynamic>? error = _asMapOrNull(responseMap?['error']);
final Object? nestedCode = error?['code'];
if (nestedCode is String) {
return nestedCode;
}
return null;
}
String _extractMessage(Map<String, dynamic>? responseMap, String? fallback) {
final Object? direct = responseMap?['message'];
if (direct is String && direct.trim().isNotEmpty) {
return direct;
}
final Map<String, dynamic>? error = _asMapOrNull(responseMap?['error']);
final Object? nested = error?['message'];
if (nested is String && nested.trim().isNotEmpty) {
return nested;
}
return fallback?.trim().isNotEmpty == true
? fallback!.trim()
: 'Backend request failed';
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:clock/clock.dart';
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/core/network/interceptors/auth_interceptor.dart';
import 'package:relationship_saver/core/network/interceptors/refresh_token_interceptor.dart';
import 'package:relationship_saver/core/network/interceptors/request_metadata_interceptor.dart';
import 'package:relationship_saver/core/network/interceptors/retry_interceptor.dart';
import 'package:uuid/uuid.dart';
/// Builds configured Dio clients for backend integration.
class DioFactory {
DioFactory._();
/// Creates Dio client with auth, retry, and refresh handling.
static Dio create({
required String baseUrl,
required TokenStore tokenStore,
Uuid? uuid,
Clock? clock,
HttpClientAdapter? httpClientAdapter,
}) {
final BaseOptions options = BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: const <String, String>{
'Accept': 'application/json',
'Content-Type': 'application/json',
},
);
final Dio dio = Dio(options);
final Dio refreshDio = Dio(options);
if (httpClientAdapter != null) {
dio.httpClientAdapter = httpClientAdapter;
refreshDio.httpClientAdapter = httpClientAdapter;
}
dio.interceptors.addAll(<Interceptor>[
RequestMetadataInterceptor(uuid: uuid),
AuthInterceptor(tokenStore: tokenStore),
RefreshTokenInterceptor(
dio: dio,
refreshDio: refreshDio,
tokenStore: tokenStore,
clock: clock,
),
RetryInterceptor(dio: dio, clock: clock),
]);
return dio;
}
}
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
/// Adds bearer access token to outgoing requests.
class AuthInterceptor extends Interceptor {
AuthInterceptor({required TokenStore tokenStore}) : _tokenStore = tokenStore;
final TokenStore _tokenStore;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (options.extra[NetworkConstants.extraSkipAuth] == true) {
handler.next(options);
return;
}
final session = await _tokenStore.read();
if (session?.accessToken case final String accessToken
when accessToken.isNotEmpty) {
options.headers[NetworkConstants.authorizationHeader] =
'Bearer $accessToken';
}
handler.next(options);
}
}
@@ -0,0 +1,154 @@
import 'package:clock/clock.dart';
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/core/network/backend_exception.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Handles access-token refresh on 401 responses and retries the original call.
class RefreshTokenInterceptor extends Interceptor {
RefreshTokenInterceptor({
required Dio dio,
required Dio refreshDio,
required TokenStore tokenStore,
Clock? clock,
}) : _dio = dio,
_refreshDio = refreshDio,
_tokenStore = tokenStore,
_clock = clock ?? const Clock();
final Dio _dio;
final Dio _refreshDio;
final TokenStore _tokenStore;
final Clock _clock;
Future<AuthRefreshResponse?>? _ongoingRefresh;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (!_shouldHandle(err.requestOptions, err.response?.statusCode)) {
handler.next(err);
return;
}
try {
final AuthRefreshResponse? refreshed = await _refreshOnce();
if (refreshed == null) {
await _tokenStore.clear();
handler.reject(_asAuthExpired(err));
return;
}
final RequestOptions retried = err.requestOptions.copyWith(
headers: <String, dynamic>{
...err.requestOptions.headers,
NetworkConstants.authorizationHeader:
'Bearer ${refreshed.accessToken}',
},
extra: <String, dynamic>{
...err.requestOptions.extra,
NetworkConstants.extraDidRefresh: true,
},
);
final Response<dynamic> response = await _dio.fetch<dynamic>(retried);
handler.resolve(response);
} on DioException catch (refreshFailure) {
await _tokenStore.clear();
handler.reject(
DioException(
requestOptions: err.requestOptions,
response: refreshFailure.response,
error: const AuthExpiredException('Authentication expired'),
type: DioExceptionType.badResponse,
),
);
} catch (_) {
await _tokenStore.clear();
handler.reject(_asAuthExpired(err));
}
}
bool _shouldHandle(RequestOptions options, int? statusCode) {
if (statusCode != 401) {
return false;
}
if (options.extra[NetworkConstants.extraSkipRefresh] == true ||
options.extra[NetworkConstants.extraDidRefresh] == true) {
return false;
}
return !options.path.endsWith('/v1/auth/refresh');
}
Future<AuthRefreshResponse?> _refreshOnce() async {
final Future<AuthRefreshResponse?>? inflight = _ongoingRefresh;
if (inflight != null) {
return inflight;
}
final Future<AuthRefreshResponse?> future = _refreshInternal();
_ongoingRefresh = future;
try {
return await future;
} finally {
_ongoingRefresh = null;
}
}
Future<AuthRefreshResponse?> _refreshInternal() async {
final AuthSession? current = await _tokenStore.read();
if (current == null || current.refreshToken.isEmpty) {
return null;
}
final Response<dynamic> response = await _refreshDio.post<dynamic>(
'/v1/auth/refresh',
data: AuthRefreshRequest(refreshToken: current.refreshToken).toJson(),
options: Options(
headers: <String, dynamic>{
NetworkConstants.requestIdHeader:
'refresh-${_clock.now().microsecondsSinceEpoch}',
},
extra: <String, dynamic>{
NetworkConstants.extraSkipAuth: true,
NetworkConstants.extraSkipRefresh: true,
},
),
);
final AuthRefreshResponse refreshed = AuthRefreshResponse.fromJson(
_asJsonMap(response.data),
);
final AuthSession updated = current.copyWith(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken ?? current.refreshToken,
expiresAt: refreshed.expiresAt,
);
await _tokenStore.write(updated);
return refreshed;
}
Map<String, dynamic> _asJsonMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map<Object?, Object?>) {
return value.map<String, dynamic>(
(Object? key, Object? val) => MapEntry(key.toString(), val),
);
}
throw const FormatException('Expected JSON object');
}
DioException _asAuthExpired(DioException source) {
return DioException(
requestOptions: source.requestOptions,
response: source.response,
type: DioExceptionType.badResponse,
error: const AuthExpiredException('Authentication expired'),
);
}
}
@@ -0,0 +1,19 @@
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
import 'package:uuid/uuid.dart';
/// Adds per-request metadata headers such as request ID.
class RequestMetadataInterceptor extends Interceptor {
RequestMetadataInterceptor({Uuid? uuid}) : _uuid = uuid ?? const Uuid();
final Uuid _uuid;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers.putIfAbsent(
NetworkConstants.requestIdHeader,
() => _uuid.v4(),
);
handler.next(options);
}
}
@@ -0,0 +1,91 @@
import 'dart:math' as math;
import 'package:clock/clock.dart';
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
/// Retries transient failures with exponential backoff.
class RetryInterceptor extends Interceptor {
RetryInterceptor({
required Dio dio,
this.maxRetries = 2,
this.baseDelay = const Duration(milliseconds: 200),
Clock? clock,
}) : _dio = dio,
_clock = clock ?? const Clock();
final Dio _dio;
final int maxRetries;
final Duration baseDelay;
final Clock _clock;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final RequestOptions request = err.requestOptions;
final int attempt =
(request.extra[NetworkConstants.extraRetryAttempt] as int?) ?? 0;
if (!_shouldRetry(err, request) || attempt >= maxRetries) {
handler.next(err);
return;
}
final int multiplier = math.pow(2, attempt).toInt();
final Duration delay = Duration(
milliseconds: baseDelay.inMilliseconds * multiplier,
);
final DateTime wakeAt = _clock.now().add(delay);
final Duration sleepFor = wakeAt.difference(_clock.now());
await Future<void>.delayed(sleepFor.isNegative ? Duration.zero : sleepFor);
final RequestOptions retried = request.copyWith(
extra: <String, dynamic>{
...request.extra,
NetworkConstants.extraRetryAttempt: attempt + 1,
},
);
try {
final Response<dynamic> response = await _dio.fetch<dynamic>(retried);
handler.resolve(response);
} on DioException catch (retryError) {
handler.next(retryError);
}
}
bool _shouldRetry(DioException error, RequestOptions request) {
if (!_isTransient(error)) {
return false;
}
final String method = request.method.toUpperCase();
if (method == 'GET' || method == 'HEAD' || method == 'OPTIONS') {
return true;
}
final Object? idempotency =
request.headers[NetworkConstants.idempotencyKeyHeader];
return idempotency is String && idempotency.trim().isNotEmpty;
}
bool _isTransient(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionError:
return true;
case DioExceptionType.badResponse:
final int? status = error.response?.statusCode;
return status != null && status >= 500 && status < 600;
case DioExceptionType.badCertificate:
case DioExceptionType.cancel:
case DioExceptionType.unknown:
return false;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/// HTTP header keys and Dio `extra` keys used by network interceptors.
class NetworkConstants {
NetworkConstants._();
static const String authorizationHeader = 'Authorization';
static const String requestIdHeader = 'X-Request-Id';
static const String idempotencyKeyHeader = 'Idempotency-Key';
static const String extraSkipAuth = 'skipAuth';
static const String extraSkipRefresh = 'skipRefresh';
static const String extraDidRefresh = 'didRefresh';
static const String extraRetryAttempt = 'retryAttempt';
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:clock/clock.dart';
/// Shared clock helper for deterministic testing.
class TimeProvider {
TimeProvider._();
/// Current wall-clock time.
static DateTime now() => clock.now();
}