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();
}
@@ -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})',
);
}
}
}
+55
View File
@@ -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,
),
],
),
),
);
}
}