Scaffold backend gateway and integration docs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
/// Domain-level backend boundary independent of HTTP transport.
|
||||
abstract interface class BackendGateway {
|
||||
/// Authenticates user and returns active session.
|
||||
Future<AuthSession> signIn(SignInRequest request);
|
||||
|
||||
/// Signs out current user.
|
||||
Future<void> signOut();
|
||||
|
||||
/// Returns current user profile.
|
||||
Future<UserProfile> me();
|
||||
|
||||
/// Pulls remote changes since optional cursor.
|
||||
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200});
|
||||
|
||||
/// Pushes local changes to backend.
|
||||
Future<SyncPushResult> pushChanges({
|
||||
required List<ChangeEnvelope> changes,
|
||||
String? cursor,
|
||||
});
|
||||
|
||||
/// Fetches recommendation signals feed.
|
||||
Future<SignalsFeed> getSignalsFeed({
|
||||
String? cursor,
|
||||
DateTime? since,
|
||||
int limit = 50,
|
||||
Set<String>? personIds,
|
||||
});
|
||||
|
||||
/// Acknowledges a signal action.
|
||||
Future<void> acknowledgeSignal({
|
||||
required String signalId,
|
||||
required SignalAction action,
|
||||
DateTime? at,
|
||||
});
|
||||
|
||||
/// Initializes upload metadata and pre-signed URL.
|
||||
Future<UploadInitResult> initUpload({
|
||||
required UploadPurpose purpose,
|
||||
required String contentType,
|
||||
required int sizeBytes,
|
||||
});
|
||||
|
||||
/// Resolves a temporary download URL.
|
||||
Future<DownloadUrl> getDownloadUrl({required String fileId});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Deterministic fake backend for local development and tests.
|
||||
class BackendGatewayFake implements BackendGateway {
|
||||
BackendGatewayFake({Clock? clock, Uuid? uuid})
|
||||
: _clock = clock ?? const Clock(),
|
||||
_uuid = uuid ?? const Uuid();
|
||||
|
||||
final Clock _clock;
|
||||
final Uuid _uuid;
|
||||
|
||||
static final DateTime _seedTime = DateTime.utc(2026, 1, 1, 8);
|
||||
|
||||
@override
|
||||
Future<void> acknowledgeSignal({
|
||||
required String signalId,
|
||||
required SignalAction action,
|
||||
DateTime? at,
|
||||
}) async {
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DownloadUrl> getDownloadUrl({required String fileId}) async {
|
||||
return DownloadUrl(
|
||||
downloadUrl: 'https://files.example.com/download/$fileId',
|
||||
expiresAt: _seedTime.add(const Duration(hours: 1)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SignalsFeed> getSignalsFeed({
|
||||
String? cursor,
|
||||
DateTime? since,
|
||||
int limit = 50,
|
||||
Set<String>? personIds,
|
||||
}) async {
|
||||
final List<SignalItem> seeded = <SignalItem>[
|
||||
SignalItem(
|
||||
id: 'sig-1',
|
||||
type: 'deal',
|
||||
title: 'Weekend coffee promo nearby',
|
||||
description: 'Save this for your next date idea.',
|
||||
personId: personIds?.isNotEmpty == true ? personIds!.first : 'person-1',
|
||||
createdAt: _seedTime,
|
||||
metadata: const <String, dynamic>{'merchant': 'Cafe Bloom'},
|
||||
),
|
||||
SignalItem(
|
||||
id: 'sig-2',
|
||||
type: 'trend',
|
||||
title: 'Books are trending in gift ideas',
|
||||
description: 'Reading-themed gifts up 24% this week.',
|
||||
personId: personIds?.isNotEmpty == true ? personIds!.first : 'person-1',
|
||||
createdAt: _seedTime.add(const Duration(hours: 3)),
|
||||
),
|
||||
SignalItem(
|
||||
id: 'sig-3',
|
||||
type: 'recommendation',
|
||||
title: 'Try a sunset walk reminder',
|
||||
description: 'Low-effort ritual with high emotional impact.',
|
||||
personId: personIds?.isNotEmpty == true ? personIds!.first : 'person-2',
|
||||
createdAt: _seedTime.add(const Duration(hours: 6)),
|
||||
),
|
||||
];
|
||||
|
||||
return SignalsFeed(
|
||||
cursor: 'fake-cursor-1',
|
||||
items: seeded.take(limit).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UploadInitResult> initUpload({
|
||||
required UploadPurpose purpose,
|
||||
required String contentType,
|
||||
required int sizeBytes,
|
||||
}) async {
|
||||
final String fileId = 'file-${_uuid.v4()}';
|
||||
return UploadInitResult(
|
||||
uploadUrl: 'https://uploads.example.com/$fileId',
|
||||
fileId: fileId,
|
||||
expiresAt: _seedTime.add(const Duration(minutes: 15)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserProfile> me() async {
|
||||
return const UserProfile(
|
||||
id: 'user-fake',
|
||||
email: 'demo@example.com',
|
||||
displayName: 'Demo User',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) async {
|
||||
final List<ChangeEnvelope> changes = <ChangeEnvelope>[
|
||||
ChangeEnvelope(
|
||||
schemaVersion: 1,
|
||||
entityType: 'person',
|
||||
entityId: 'person-1',
|
||||
op: ChangeOperation.upsert,
|
||||
modifiedAt: _seedTime,
|
||||
clientMutationId: 'cm-1',
|
||||
payload: const <String, dynamic>{'name': 'Alex', 'importance': 5},
|
||||
),
|
||||
];
|
||||
|
||||
return SyncPullResult(
|
||||
cursor: 'fake-pull-cursor-1',
|
||||
changes: changes.take(limit).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncPushResult> pushChanges({
|
||||
required List<ChangeEnvelope> changes,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final DateTime now = _clock.now().toUtc();
|
||||
final List<MutationAck> accepted = changes
|
||||
.map(
|
||||
(ChangeEnvelope change) => MutationAck(
|
||||
clientMutationId: change.clientMutationId,
|
||||
serverMutationId: 'srv-${change.clientMutationId}',
|
||||
acceptedAt: now,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
return SyncPushResult(
|
||||
cursor: 'fake-push-cursor-1',
|
||||
accepted: accepted,
|
||||
rejected: const <MutationRejection>[],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthSession> signIn(SignInRequest request) async {
|
||||
final DateTime now = _clock.now().toUtc();
|
||||
return AuthSession(
|
||||
accessToken: 'fake-access-token',
|
||||
refreshToken: 'fake-refresh-token',
|
||||
expiresAt: now.add(const Duration(hours: 1)),
|
||||
user: UserProfile(
|
||||
id: 'user-fake',
|
||||
email: request.email ?? 'demo@example.com',
|
||||
displayName: 'Demo User',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/auth/secure_token_store.dart';
|
||||
import 'package:relationship_saver/core/auth/token_store.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/core/network/dio_factory.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway_fake.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway_rest.dart';
|
||||
|
||||
/// Provides token storage implementation.
|
||||
final Provider<TokenStore> tokenStoreProvider = Provider<TokenStore>((Ref ref) {
|
||||
return SecureTokenStore();
|
||||
});
|
||||
|
||||
/// Provides configured Dio client for backend transport.
|
||||
final Provider<Dio> backendDioProvider = Provider<Dio>((Ref ref) {
|
||||
final TokenStore tokenStore = ref.watch(tokenStoreProvider);
|
||||
return DioFactory.create(
|
||||
baseUrl: AppConfig.backendBaseUrl,
|
||||
tokenStore: tokenStore,
|
||||
);
|
||||
});
|
||||
|
||||
/// Provides backend gateway implementation.
|
||||
final Provider<BackendGateway> backendGatewayProvider =
|
||||
Provider<BackendGateway>((Ref ref) {
|
||||
if (AppConfig.useFakeBackend) {
|
||||
return BackendGatewayFake();
|
||||
}
|
||||
|
||||
return BackendGatewayRest(
|
||||
dio: ref.watch(backendDioProvider),
|
||||
tokenStore: ref.watch(tokenStoreProvider),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
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/dio_error_mapper.dart';
|
||||
import 'package:relationship_saver/core/network/network_constants.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
import 'package:relationship_saver/integrations/backend/sync_envelope_validator.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// REST implementation of [BackendGateway] backed by Dio.
|
||||
class BackendGatewayRest implements BackendGateway {
|
||||
BackendGatewayRest({
|
||||
required Dio dio,
|
||||
required TokenStore tokenStore,
|
||||
Uuid? uuid,
|
||||
Clock? clock,
|
||||
this.deviceId = 'device-local',
|
||||
}) : _dio = dio,
|
||||
_tokenStore = tokenStore,
|
||||
_uuid = uuid ?? const Uuid(),
|
||||
_clock = clock ?? const Clock();
|
||||
|
||||
final Dio _dio;
|
||||
final TokenStore _tokenStore;
|
||||
final Uuid _uuid;
|
||||
final Clock _clock;
|
||||
final String deviceId;
|
||||
|
||||
@override
|
||||
Future<void> acknowledgeSignal({
|
||||
required String signalId,
|
||||
required SignalAction action,
|
||||
DateTime? at,
|
||||
}) {
|
||||
return _guard(() async {
|
||||
final SignalAckRequest body = SignalAckRequest(
|
||||
action: action,
|
||||
at: at ?? _clock.now(),
|
||||
);
|
||||
|
||||
await _dio.post<dynamic>(
|
||||
'/v1/signals/$signalId/ack',
|
||||
data: body.toJson(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DownloadUrl> getDownloadUrl({required String fileId}) {
|
||||
return _guard(() async {
|
||||
final Response<dynamic> response = await _dio.get<dynamic>(
|
||||
'/v1/files/$fileId',
|
||||
);
|
||||
return DownloadUrl.fromJson(_asJsonMap(response.data));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SignalsFeed> getSignalsFeed({
|
||||
String? cursor,
|
||||
DateTime? since,
|
||||
int limit = 50,
|
||||
Set<String>? personIds,
|
||||
}) {
|
||||
return _guard(() async {
|
||||
final Map<String, dynamic> query = <String, dynamic>{'limit': limit};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
query['cursor'] = cursor;
|
||||
}
|
||||
if (since != null) {
|
||||
query['since'] = since.toUtc().toIso8601String();
|
||||
}
|
||||
if (personIds != null && personIds.isNotEmpty) {
|
||||
query['personId'] = personIds.toList(growable: false);
|
||||
}
|
||||
|
||||
final Response<dynamic> response = await _dio.get<dynamic>(
|
||||
'/v1/signals',
|
||||
queryParameters: query,
|
||||
);
|
||||
return SignalsFeed.fromJson(_asJsonMap(response.data));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UploadInitResult> initUpload({
|
||||
required UploadPurpose purpose,
|
||||
required String contentType,
|
||||
required int sizeBytes,
|
||||
}) {
|
||||
return _guard(() async {
|
||||
final UploadInitRequest request = UploadInitRequest(
|
||||
purpose: purpose,
|
||||
contentType: contentType,
|
||||
sizeBytes: sizeBytes,
|
||||
);
|
||||
final Response<dynamic> response = await _dio.post<dynamic>(
|
||||
'/v1/uploads',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return UploadInitResult.fromJson(_asJsonMap(response.data));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserProfile> me() {
|
||||
return _guard(() async {
|
||||
final Response<dynamic> response = await _dio.get<dynamic>('/v1/me');
|
||||
return UserProfile.fromJson(_asJsonMap(response.data));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) {
|
||||
return _guard(() async {
|
||||
final Map<String, dynamic> query = <String, dynamic>{'limit': limit};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
query['cursor'] = cursor;
|
||||
}
|
||||
|
||||
final Response<dynamic> response = await _dio.get<dynamic>(
|
||||
'/v1/sync/pull',
|
||||
queryParameters: query,
|
||||
);
|
||||
return SyncPullResult.fromJson(_asJsonMap(response.data));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncPushResult> pushChanges({
|
||||
required List<ChangeEnvelope> changes,
|
||||
String? cursor,
|
||||
}) {
|
||||
return _guard(() async {
|
||||
SyncEnvelopeValidator.validateAll(changes);
|
||||
|
||||
final String idempotencyKey = _uuid.v4();
|
||||
final SyncPushRequest request = SyncPushRequest(
|
||||
deviceId: deviceId,
|
||||
cursor: cursor,
|
||||
changes: changes,
|
||||
idempotencyKey: idempotencyKey,
|
||||
);
|
||||
|
||||
final Response<dynamic> response = await _dio.post<dynamic>(
|
||||
'/v1/sync/push',
|
||||
data: request.toJson(),
|
||||
options: Options(
|
||||
headers: <String, dynamic>{
|
||||
NetworkConstants.idempotencyKeyHeader: idempotencyKey,
|
||||
},
|
||||
),
|
||||
);
|
||||
return SyncPushResult.fromJson(_asJsonMap(response.data));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthSession> signIn(SignInRequest request) {
|
||||
return _guard(() async {
|
||||
final Response<dynamic> response = await _dio.post<dynamic>(
|
||||
'/v1/auth/sign-in',
|
||||
data: request.toJson(),
|
||||
options: Options(
|
||||
extra: const <String, dynamic>{
|
||||
NetworkConstants.extraSkipAuth: true,
|
||||
NetworkConstants.extraSkipRefresh: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final AuthSession session = AuthSession.fromJson(
|
||||
_asJsonMap(response.data),
|
||||
);
|
||||
await _tokenStore.write(session);
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await _guard(() async {
|
||||
await _dio.post<dynamic>('/v1/auth/sign-out');
|
||||
});
|
||||
} finally {
|
||||
await _tokenStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _guard<T>(Future<T> Function() run) async {
|
||||
try {
|
||||
return await run();
|
||||
} on DioException catch (error) {
|
||||
throw mapDioException(error);
|
||||
}
|
||||
}
|
||||
|
||||
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 FormatException(
|
||||
'Expected JSON object response, got ${value.runtimeType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Backend Mappers
|
||||
|
||||
This folder is reserved for DTO <-> domain mappers as domain aggregates are introduced.
|
||||
Current MVP uses shared typed models directly to keep the gateway layer lean.
|
||||
@@ -0,0 +1,290 @@
|
||||
// ignore_for_file: invalid_annotation_target
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'backend_models.freezed.dart';
|
||||
part 'backend_models.g.dart';
|
||||
|
||||
/// Supported authentication methods.
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum SignInMethod {
|
||||
/// Email link based sign-in.
|
||||
emailMagicLink,
|
||||
|
||||
/// Email/password sign-in.
|
||||
password,
|
||||
|
||||
/// OIDC token based sign-in.
|
||||
oidc,
|
||||
}
|
||||
|
||||
/// Allowed change operation types.
|
||||
@JsonEnum(alwaysCreate: true)
|
||||
enum ChangeOperation {
|
||||
/// Create or update entity state.
|
||||
@JsonValue('upsert')
|
||||
upsert,
|
||||
|
||||
/// Remove entity state.
|
||||
@JsonValue('delete')
|
||||
delete,
|
||||
}
|
||||
|
||||
/// Action taken by user against a signal item.
|
||||
@JsonEnum(alwaysCreate: true)
|
||||
enum SignalAction {
|
||||
/// Item was viewed.
|
||||
viewed,
|
||||
|
||||
/// Item was dismissed.
|
||||
dismissed,
|
||||
|
||||
/// Item was saved.
|
||||
saved,
|
||||
|
||||
/// Item resulted in purchase.
|
||||
purchased,
|
||||
}
|
||||
|
||||
/// Upload category.
|
||||
@JsonEnum(alwaysCreate: true)
|
||||
enum UploadPurpose {
|
||||
/// Avatar upload.
|
||||
@JsonValue('avatar')
|
||||
avatar,
|
||||
|
||||
/// Generic attachment upload.
|
||||
@JsonValue('attachment')
|
||||
attachment,
|
||||
}
|
||||
|
||||
/// Request body for sign-in.
|
||||
@freezed
|
||||
abstract class SignInRequest with _$SignInRequest {
|
||||
const factory SignInRequest({
|
||||
required SignInMethod method,
|
||||
String? email,
|
||||
String? password,
|
||||
String? idToken,
|
||||
}) = _SignInRequest;
|
||||
|
||||
factory SignInRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$SignInRequestFromJson(json);
|
||||
}
|
||||
|
||||
/// User profile returned by backend.
|
||||
@freezed
|
||||
abstract class UserProfile with _$UserProfile {
|
||||
const factory UserProfile({
|
||||
required String id,
|
||||
String? email,
|
||||
String? displayName,
|
||||
String? avatarFileId,
|
||||
}) = _UserProfile;
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserProfileFromJson(json);
|
||||
}
|
||||
|
||||
/// Auth session bundle used by gateway and token storage.
|
||||
@freezed
|
||||
abstract class AuthSession with _$AuthSession {
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
const factory AuthSession({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required DateTime expiresAt,
|
||||
required UserProfile user,
|
||||
}) = _AuthSession;
|
||||
|
||||
factory AuthSession.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthSessionFromJson(json);
|
||||
}
|
||||
|
||||
/// Refresh token request body.
|
||||
@freezed
|
||||
abstract class AuthRefreshRequest with _$AuthRefreshRequest {
|
||||
const factory AuthRefreshRequest({required String refreshToken}) =
|
||||
_AuthRefreshRequest;
|
||||
|
||||
factory AuthRefreshRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthRefreshRequestFromJson(json);
|
||||
}
|
||||
|
||||
/// Refresh token response body.
|
||||
@freezed
|
||||
abstract class AuthRefreshResponse with _$AuthRefreshResponse {
|
||||
const factory AuthRefreshResponse({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
required DateTime expiresAt,
|
||||
}) = _AuthRefreshResponse;
|
||||
|
||||
factory AuthRefreshResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthRefreshResponseFromJson(json);
|
||||
}
|
||||
|
||||
/// Generic envelope for offline-first change operations.
|
||||
@freezed
|
||||
abstract class ChangeEnvelope with _$ChangeEnvelope {
|
||||
const factory ChangeEnvelope({
|
||||
required int schemaVersion,
|
||||
required String entityType,
|
||||
required String entityId,
|
||||
required ChangeOperation op,
|
||||
required DateTime modifiedAt,
|
||||
required String clientMutationId,
|
||||
Map<String, dynamic>? payload,
|
||||
}) = _ChangeEnvelope;
|
||||
|
||||
factory ChangeEnvelope.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChangeEnvelopeFromJson(json);
|
||||
}
|
||||
|
||||
/// Push request for batched local changes.
|
||||
@freezed
|
||||
abstract class SyncPushRequest with _$SyncPushRequest {
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
const factory SyncPushRequest({
|
||||
required String deviceId,
|
||||
String? cursor,
|
||||
required List<ChangeEnvelope> changes,
|
||||
required String idempotencyKey,
|
||||
}) = _SyncPushRequest;
|
||||
|
||||
factory SyncPushRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$SyncPushRequestFromJson(json);
|
||||
}
|
||||
|
||||
/// Acknowledgement for an accepted mutation.
|
||||
@freezed
|
||||
abstract class MutationAck with _$MutationAck {
|
||||
const factory MutationAck({
|
||||
required String clientMutationId,
|
||||
String? serverMutationId,
|
||||
DateTime? acceptedAt,
|
||||
}) = _MutationAck;
|
||||
|
||||
factory MutationAck.fromJson(Map<String, dynamic> json) =>
|
||||
_$MutationAckFromJson(json);
|
||||
}
|
||||
|
||||
/// Rejection detail for a mutation.
|
||||
@freezed
|
||||
abstract class MutationRejection with _$MutationRejection {
|
||||
const factory MutationRejection({
|
||||
required String clientMutationId,
|
||||
required String code,
|
||||
required String message,
|
||||
}) = _MutationRejection;
|
||||
|
||||
factory MutationRejection.fromJson(Map<String, dynamic> json) =>
|
||||
_$MutationRejectionFromJson(json);
|
||||
}
|
||||
|
||||
/// Result body returned by sync push.
|
||||
@freezed
|
||||
abstract class SyncPushResult with _$SyncPushResult {
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
const factory SyncPushResult({
|
||||
required String cursor,
|
||||
@Default(<MutationAck>[]) List<MutationAck> accepted,
|
||||
@Default(<MutationRejection>[]) List<MutationRejection> rejected,
|
||||
}) = _SyncPushResult;
|
||||
|
||||
factory SyncPushResult.fromJson(Map<String, dynamic> json) =>
|
||||
_$SyncPushResultFromJson(json);
|
||||
}
|
||||
|
||||
/// Result body returned by sync pull.
|
||||
@freezed
|
||||
abstract class SyncPullResult with _$SyncPullResult {
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
const factory SyncPullResult({
|
||||
required String cursor,
|
||||
@Default(<ChangeEnvelope>[]) List<ChangeEnvelope> changes,
|
||||
}) = _SyncPullResult;
|
||||
|
||||
factory SyncPullResult.fromJson(Map<String, dynamic> json) =>
|
||||
_$SyncPullResultFromJson(json);
|
||||
}
|
||||
|
||||
/// Signals feed item.
|
||||
@freezed
|
||||
abstract class SignalItem with _$SignalItem {
|
||||
const factory SignalItem({
|
||||
required String id,
|
||||
required String type,
|
||||
required String title,
|
||||
String? description,
|
||||
String? personId,
|
||||
required DateTime createdAt,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) = _SignalItem;
|
||||
|
||||
factory SignalItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$SignalItemFromJson(json);
|
||||
}
|
||||
|
||||
/// Cursor-based signals response.
|
||||
@freezed
|
||||
abstract class SignalsFeed with _$SignalsFeed {
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
const factory SignalsFeed({
|
||||
String? cursor,
|
||||
@Default(<SignalItem>[]) List<SignalItem> items,
|
||||
}) = _SignalsFeed;
|
||||
|
||||
factory SignalsFeed.fromJson(Map<String, dynamic> json) =>
|
||||
_$SignalsFeedFromJson(json);
|
||||
}
|
||||
|
||||
/// Signal acknowledgement request body.
|
||||
@freezed
|
||||
abstract class SignalAckRequest with _$SignalAckRequest {
|
||||
const factory SignalAckRequest({
|
||||
required SignalAction action,
|
||||
required DateTime at,
|
||||
}) = _SignalAckRequest;
|
||||
|
||||
factory SignalAckRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$SignalAckRequestFromJson(json);
|
||||
}
|
||||
|
||||
/// Upload init request.
|
||||
@freezed
|
||||
abstract class UploadInitRequest with _$UploadInitRequest {
|
||||
const factory UploadInitRequest({
|
||||
required UploadPurpose purpose,
|
||||
required String contentType,
|
||||
required int sizeBytes,
|
||||
}) = _UploadInitRequest;
|
||||
|
||||
factory UploadInitRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$UploadInitRequestFromJson(json);
|
||||
}
|
||||
|
||||
/// Upload init response.
|
||||
@freezed
|
||||
abstract class UploadInitResult with _$UploadInitResult {
|
||||
const factory UploadInitResult({
|
||||
required String uploadUrl,
|
||||
required String fileId,
|
||||
required DateTime expiresAt,
|
||||
}) = _UploadInitResult;
|
||||
|
||||
factory UploadInitResult.fromJson(Map<String, dynamic> json) =>
|
||||
_$UploadInitResultFromJson(json);
|
||||
}
|
||||
|
||||
/// Download URL response.
|
||||
@freezed
|
||||
abstract class DownloadUrl with _$DownloadUrl {
|
||||
const factory DownloadUrl({
|
||||
required String downloadUrl,
|
||||
required DateTime expiresAt,
|
||||
}) = _DownloadUrl;
|
||||
|
||||
factory DownloadUrl.fromJson(Map<String, dynamic> json) =>
|
||||
_$DownloadUrlFromJson(json);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'backend_models.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_SignInRequest _$SignInRequestFromJson(Map<String, dynamic> json) =>
|
||||
_SignInRequest(
|
||||
method: $enumDecode(_$SignInMethodEnumMap, json['method']),
|
||||
email: json['email'] as String?,
|
||||
password: json['password'] as String?,
|
||||
idToken: json['idToken'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SignInRequestToJson(_SignInRequest instance) =>
|
||||
<String, dynamic>{
|
||||
'method': _$SignInMethodEnumMap[instance.method]!,
|
||||
'email': instance.email,
|
||||
'password': instance.password,
|
||||
'idToken': instance.idToken,
|
||||
};
|
||||
|
||||
const _$SignInMethodEnumMap = {
|
||||
SignInMethod.emailMagicLink: 'email_magic_link',
|
||||
SignInMethod.password: 'password',
|
||||
SignInMethod.oidc: 'oidc',
|
||||
};
|
||||
|
||||
_UserProfile _$UserProfileFromJson(Map<String, dynamic> json) => _UserProfile(
|
||||
id: json['id'] as String,
|
||||
email: json['email'] as String?,
|
||||
displayName: json['displayName'] as String?,
|
||||
avatarFileId: json['avatarFileId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserProfileToJson(_UserProfile instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'email': instance.email,
|
||||
'displayName': instance.displayName,
|
||||
'avatarFileId': instance.avatarFileId,
|
||||
};
|
||||
|
||||
_AuthSession _$AuthSessionFromJson(Map<String, dynamic> json) => _AuthSession(
|
||||
accessToken: json['accessToken'] as String,
|
||||
refreshToken: json['refreshToken'] as String,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
user: UserProfile.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthSessionToJson(_AuthSession instance) =>
|
||||
<String, dynamic>{
|
||||
'accessToken': instance.accessToken,
|
||||
'refreshToken': instance.refreshToken,
|
||||
'expiresAt': instance.expiresAt.toIso8601String(),
|
||||
'user': instance.user.toJson(),
|
||||
};
|
||||
|
||||
_AuthRefreshRequest _$AuthRefreshRequestFromJson(Map<String, dynamic> json) =>
|
||||
_AuthRefreshRequest(refreshToken: json['refreshToken'] as String);
|
||||
|
||||
Map<String, dynamic> _$AuthRefreshRequestToJson(_AuthRefreshRequest instance) =>
|
||||
<String, dynamic>{'refreshToken': instance.refreshToken};
|
||||
|
||||
_AuthRefreshResponse _$AuthRefreshResponseFromJson(Map<String, dynamic> json) =>
|
||||
_AuthRefreshResponse(
|
||||
accessToken: json['accessToken'] as String,
|
||||
refreshToken: json['refreshToken'] as String?,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthRefreshResponseToJson(
|
||||
_AuthRefreshResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'accessToken': instance.accessToken,
|
||||
'refreshToken': instance.refreshToken,
|
||||
'expiresAt': instance.expiresAt.toIso8601String(),
|
||||
};
|
||||
|
||||
_ChangeEnvelope _$ChangeEnvelopeFromJson(Map<String, dynamic> json) =>
|
||||
_ChangeEnvelope(
|
||||
schemaVersion: (json['schemaVersion'] as num).toInt(),
|
||||
entityType: json['entityType'] as String,
|
||||
entityId: json['entityId'] as String,
|
||||
op: $enumDecode(_$ChangeOperationEnumMap, json['op']),
|
||||
modifiedAt: DateTime.parse(json['modifiedAt'] as String),
|
||||
clientMutationId: json['clientMutationId'] as String,
|
||||
payload: json['payload'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChangeEnvelopeToJson(_ChangeEnvelope instance) =>
|
||||
<String, dynamic>{
|
||||
'schemaVersion': instance.schemaVersion,
|
||||
'entityType': instance.entityType,
|
||||
'entityId': instance.entityId,
|
||||
'op': _$ChangeOperationEnumMap[instance.op]!,
|
||||
'modifiedAt': instance.modifiedAt.toIso8601String(),
|
||||
'clientMutationId': instance.clientMutationId,
|
||||
'payload': instance.payload,
|
||||
};
|
||||
|
||||
const _$ChangeOperationEnumMap = {
|
||||
ChangeOperation.upsert: 'upsert',
|
||||
ChangeOperation.delete: 'delete',
|
||||
};
|
||||
|
||||
_SyncPushRequest _$SyncPushRequestFromJson(Map<String, dynamic> json) =>
|
||||
_SyncPushRequest(
|
||||
deviceId: json['deviceId'] as String,
|
||||
cursor: json['cursor'] as String?,
|
||||
changes: (json['changes'] as List<dynamic>)
|
||||
.map((e) => ChangeEnvelope.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
idempotencyKey: json['idempotencyKey'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SyncPushRequestToJson(_SyncPushRequest instance) =>
|
||||
<String, dynamic>{
|
||||
'deviceId': instance.deviceId,
|
||||
'cursor': instance.cursor,
|
||||
'changes': instance.changes.map((e) => e.toJson()).toList(),
|
||||
'idempotencyKey': instance.idempotencyKey,
|
||||
};
|
||||
|
||||
_MutationAck _$MutationAckFromJson(Map<String, dynamic> json) => _MutationAck(
|
||||
clientMutationId: json['clientMutationId'] as String,
|
||||
serverMutationId: json['serverMutationId'] as String?,
|
||||
acceptedAt: json['acceptedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['acceptedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MutationAckToJson(_MutationAck instance) =>
|
||||
<String, dynamic>{
|
||||
'clientMutationId': instance.clientMutationId,
|
||||
'serverMutationId': instance.serverMutationId,
|
||||
'acceptedAt': instance.acceptedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_MutationRejection _$MutationRejectionFromJson(Map<String, dynamic> json) =>
|
||||
_MutationRejection(
|
||||
clientMutationId: json['clientMutationId'] as String,
|
||||
code: json['code'] as String,
|
||||
message: json['message'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MutationRejectionToJson(_MutationRejection instance) =>
|
||||
<String, dynamic>{
|
||||
'clientMutationId': instance.clientMutationId,
|
||||
'code': instance.code,
|
||||
'message': instance.message,
|
||||
};
|
||||
|
||||
_SyncPushResult _$SyncPushResultFromJson(Map<String, dynamic> json) =>
|
||||
_SyncPushResult(
|
||||
cursor: json['cursor'] as String,
|
||||
accepted:
|
||||
(json['accepted'] as List<dynamic>?)
|
||||
?.map((e) => MutationAck.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <MutationAck>[],
|
||||
rejected:
|
||||
(json['rejected'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => MutationRejection.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
const <MutationRejection>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SyncPushResultToJson(_SyncPushResult instance) =>
|
||||
<String, dynamic>{
|
||||
'cursor': instance.cursor,
|
||||
'accepted': instance.accepted.map((e) => e.toJson()).toList(),
|
||||
'rejected': instance.rejected.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
_SyncPullResult _$SyncPullResultFromJson(Map<String, dynamic> json) =>
|
||||
_SyncPullResult(
|
||||
cursor: json['cursor'] as String,
|
||||
changes:
|
||||
(json['changes'] as List<dynamic>?)
|
||||
?.map((e) => ChangeEnvelope.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ChangeEnvelope>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SyncPullResultToJson(_SyncPullResult instance) =>
|
||||
<String, dynamic>{
|
||||
'cursor': instance.cursor,
|
||||
'changes': instance.changes.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
_SignalItem _$SignalItemFromJson(Map<String, dynamic> json) => _SignalItem(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
personId: json['personId'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SignalItemToJson(_SignalItem instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'personId': instance.personId,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'metadata': instance.metadata,
|
||||
};
|
||||
|
||||
_SignalsFeed _$SignalsFeedFromJson(Map<String, dynamic> json) => _SignalsFeed(
|
||||
cursor: json['cursor'] as String?,
|
||||
items:
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map((e) => SignalItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <SignalItem>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SignalsFeedToJson(_SignalsFeed instance) =>
|
||||
<String, dynamic>{
|
||||
'cursor': instance.cursor,
|
||||
'items': instance.items.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
_SignalAckRequest _$SignalAckRequestFromJson(Map<String, dynamic> json) =>
|
||||
_SignalAckRequest(
|
||||
action: $enumDecode(_$SignalActionEnumMap, json['action']),
|
||||
at: DateTime.parse(json['at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SignalAckRequestToJson(_SignalAckRequest instance) =>
|
||||
<String, dynamic>{
|
||||
'action': _$SignalActionEnumMap[instance.action]!,
|
||||
'at': instance.at.toIso8601String(),
|
||||
};
|
||||
|
||||
const _$SignalActionEnumMap = {
|
||||
SignalAction.viewed: 'viewed',
|
||||
SignalAction.dismissed: 'dismissed',
|
||||
SignalAction.saved: 'saved',
|
||||
SignalAction.purchased: 'purchased',
|
||||
};
|
||||
|
||||
_UploadInitRequest _$UploadInitRequestFromJson(Map<String, dynamic> json) =>
|
||||
_UploadInitRequest(
|
||||
purpose: $enumDecode(_$UploadPurposeEnumMap, json['purpose']),
|
||||
contentType: json['contentType'] as String,
|
||||
sizeBytes: (json['sizeBytes'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UploadInitRequestToJson(_UploadInitRequest instance) =>
|
||||
<String, dynamic>{
|
||||
'purpose': _$UploadPurposeEnumMap[instance.purpose]!,
|
||||
'contentType': instance.contentType,
|
||||
'sizeBytes': instance.sizeBytes,
|
||||
};
|
||||
|
||||
const _$UploadPurposeEnumMap = {
|
||||
UploadPurpose.avatar: 'avatar',
|
||||
UploadPurpose.attachment: 'attachment',
|
||||
};
|
||||
|
||||
_UploadInitResult _$UploadInitResultFromJson(Map<String, dynamic> json) =>
|
||||
_UploadInitResult(
|
||||
uploadUrl: json['uploadUrl'] as String,
|
||||
fileId: json['fileId'] as String,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UploadInitResultToJson(_UploadInitResult instance) =>
|
||||
<String, dynamic>{
|
||||
'uploadUrl': instance.uploadUrl,
|
||||
'fileId': instance.fileId,
|
||||
'expiresAt': instance.expiresAt.toIso8601String(),
|
||||
};
|
||||
|
||||
_DownloadUrl _$DownloadUrlFromJson(Map<String, dynamic> json) => _DownloadUrl(
|
||||
downloadUrl: json['downloadUrl'] as String,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadUrlToJson(_DownloadUrl instance) =>
|
||||
<String, dynamic>{
|
||||
'downloadUrl': instance.downloadUrl,
|
||||
'expiresAt': instance.expiresAt.toIso8601String(),
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:relationship_saver/core/network/backend_exception.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
/// Validation rules for outgoing sync envelopes.
|
||||
class SyncEnvelopeValidator {
|
||||
SyncEnvelopeValidator._();
|
||||
|
||||
/// Validates a list of changes before a push call.
|
||||
static void validateAll(List<ChangeEnvelope> changes) {
|
||||
for (final ChangeEnvelope change in changes) {
|
||||
validate(change);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates one change envelope.
|
||||
static void validate(ChangeEnvelope change) {
|
||||
if (change.schemaVersion <= 0) {
|
||||
throw ValidationException(
|
||||
'schemaVersion must be > 0 for ${change.clientMutationId}',
|
||||
);
|
||||
}
|
||||
|
||||
if (change.op == ChangeOperation.delete && change.payload != null) {
|
||||
throw ValidationException(
|
||||
'payload must be null for delete operation (${change.clientMutationId})',
|
||||
);
|
||||
}
|
||||
|
||||
if (change.op == ChangeOperation.upsert && change.payload == null) {
|
||||
throw ValidationException(
|
||||
'payload is required for upsert operation (${change.clientMutationId})',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: RelationshipSaverApp()));
|
||||
}
|
||||
|
||||
class RelationshipSaverApp extends StatelessWidget {
|
||||
const RelationshipSaverApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Relationship Saver',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
home: const _LandingScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LandingScreen extends StatelessWidget {
|
||||
const _LandingScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Relationship Saver')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Backend gateway scaffold is ready.',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Mode: ${AppConfig.useFakeBackend ? 'Fake gateway' : 'REST gateway'}',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Base URL: ${AppConfig.backendBaseUrl}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user