From cdd485630083071830dc2e7c31eea4ef528f6a29 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 18:07:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=84=9C=EB=B2=84=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=B0=98=20=E2=80=94=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=C2=B7=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=C2=B7=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EC=97=85=EB=A1=9C=EB=93=9C=C2=B7=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=82=AC=EC=A7=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기기 저장소를 API 로 바꾸기 전에, 모든 도메인이 함께 쓸 네트워크 계층을 먼저 갖춘다. - DATA_MODE(api|local): 기본은 서버. .env 를 읽지 않는 테스트·화면 확인은 기기 저장소로 돈다 - SessionStore: 액세스·리프레시 토큰을 secure storage 에 두고 앱을 열 때 미리 읽는다 (모바일에는 쿠키 저장소가 없어 리프레시 토큰도 secure storage 에 둔다) - DioClient: 401 이면 토큰을 한 번 갱신해 다시 보낸다(동시 요청도 갱신은 한 번). 서버가 갱신을 거절하면 세션을 끊고 AUTH_002, 서버에 닿지 못하면 세션은 두고 SYS_001 - 실패 알림은 사전 문구로 토스트 한 곳에서 띄우고 같은 코드는 잠깐 한 번만 — 호출부가 처리하는 코드는 끌 수 있다 - 사진: 경로 자리에 server-file: 를 담고, 기기 사진은 저장할 때 형식을 내용으로 확인해 올린다. 서버 사진은 Dio 로 받아 토큰 갱신을 그대로 쓴다 (PetPhotoImage·PersonAvatar) Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/api/api_paging.dart | 32 +++ flutter/lib/core/api/dio_client.dart | 198 ++++++++++++---- flutter/lib/core/api/dio_provider.dart | 20 +- flutter/lib/core/api/file_uploader.dart | 62 +++++ flutter/lib/core/api/server_file.dart | 20 ++ flutter/lib/core/api/session_store.dart | 114 +++++++++ flutter/lib/core/config/app_config.dart | 23 ++ flutter/lib/core/utils/image_type.dart | 32 +++ .../presentation/widgets/pet_photo_image.dart | 15 +- flutter/lib/main.dart | 14 +- flutter/lib/shared/widgets/person_avatar.dart | 14 +- flutter/lib/shared/widgets/photo_image.dart | 73 ++++++ flutter/lib/shared/widgets/rup_toast.dart | 11 + flutter/test/api_client_test.dart | 219 ++++++++++++++++++ flutter/test/server_file_test.dart | 48 ++++ flutter/test/support/app_harness.dart | 8 +- 16 files changed, 836 insertions(+), 67 deletions(-) create mode 100644 flutter/lib/core/api/api_paging.dart create mode 100644 flutter/lib/core/api/file_uploader.dart create mode 100644 flutter/lib/core/api/server_file.dart create mode 100644 flutter/lib/core/api/session_store.dart create mode 100644 flutter/lib/core/config/app_config.dart create mode 100644 flutter/lib/core/utils/image_type.dart create mode 100644 flutter/lib/shared/widgets/photo_image.dart create mode 100644 flutter/test/api_client_test.dart create mode 100644 flutter/test/server_file_test.dart diff --git a/flutter/lib/core/api/api_paging.dart b/flutter/lib/core/api/api_paging.dart new file mode 100644 index 0000000..8ec89bb --- /dev/null +++ b/flutter/lib/core/api/api_paging.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; + +/// 목록 API 한 번에 읽는 최대 개수 (backend PAGINATION_MAX_LIMIT) +const int kApiPageLimit = 100; + +/// 목록을 끝까지 읽는 상한 — 서버가 nextCursor 를 계속 주는 사고로 멈추지 않는 일을 막는다 +const int _kMaxPages = 1000; + +/// 커서 페이지네이션 목록({items, pageInfo})을 마지막 페이지까지 읽어 이어 붙인다. +/// 앱은 지금 전체 기록을 들고 화면에서 거른다 — 기록이 아주 많아지면 기간 조회로 바꾼다. +Future> fetchAllPages( + Dio dio, + String path, + T Function(Map json) parse, +) async { + final items = []; + String? cursor; + for (var page = 0; page < _kMaxPages; page++) { + final response = await dio.get>( + path, + queryParameters: {'limit': kApiPageLimit, 'cursor': ?cursor}, + ); + final data = response.data!; + for (final item in data['items']! as List) { + items.add(parse(item! as Map)); + } + final pageInfo = data['pageInfo']! as Map; + cursor = pageInfo['nextCursor'] as String?; + if (pageInfo['hasNext'] != true || cursor == null) break; + } + return items; +} diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 13141bb..26cac72 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -1,49 +1,73 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'api_exception.dart'; +import 'session_store.dart'; -/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다. -const String kJwtTokenKey = 'jwt_token'; +export 'session_store.dart' show kJwtTokenKey, kRefreshTokenKey; const Duration _kConnectTimeout = Duration(seconds: 15); const Duration _kReceiveTimeout = Duration(seconds: 15); +/// 요청 extra — 실패해도 전역 알림을 띄우지 않을 에러 코드 목록 (호출한 쪽이 그 코드를 직접 처리할 때) +/// dio.post('/diaries', data: ..., options: Options(extra: {kSilentCodesExtra: ['DIARY_001']})) +const String kSilentCodesExtra = 'silentCodes'; + +/// 요청 extra — 실패해도 알림을 전혀 띄우지 않는다 (사진 불러오기처럼 화면이 빈칸으로 처리할 때) +const String kSilentExtra = 'silent'; + +/// 토큰을 갱신해 한 번 다시 보낸 요청 표시 — 같은 요청을 두 번 갱신하지 않는다 +const String _kRetriedExtra = 'authRetried'; + +/// 요청 extra — 갱신하려다 서버에 닿지 못했다 (세션은 그대로 둔다) +const String _kRefreshUnreachableExtra = 'refreshUnreachable'; + +/// 토큰 갱신 결과 +enum _RefreshResult { refreshed, rejected, unreachable } + +/// 토큰 갱신 경로 (backend AuthController `POST /auth/tokens`) +const String kRefreshPath = '/auth/tokens'; + +/// 정규화한 실패를 화면에 알린다 (토스트) — 호출부는 메시지를 새로 만들지 않는다 (.claude/rules/error-codes.md) +typedef ApiErrorReporter = void Function(ApiException error); + +/// 앱의 HTTP 클라이언트 — 인터셉터(토큰 첨부·응답 언래핑·토큰 갱신·에러 매핑)가 적용된 [dio] 를 낸다. +/// 세션(토큰)은 [SessionStore] 가 들고 있고, 갱신·만료도 그쪽에 반영한다. class DioClient { - static final DioClient _instance = DioClient._internal(); - late Dio _dio; - final FlutterSecureStorage _storage = const FlutterSecureStorage(); - - factory DioClient() { - return _instance; - } - - DioClient._internal() { - // 백엔드 글로벌 prefix(/api) 와 일치 - final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api'; - - _dio = Dio(BaseOptions( + DioClient({ + required String baseUrl, + required SessionStore session, + ApiErrorReporter? onError, + HttpClientAdapter? httpClientAdapter, + }) : _session = session, + _onError = onError { + final options = BaseOptions( + // 백엔드 글로벌 prefix(/api) 와 일치 baseUrl: baseUrl, connectTimeout: _kConnectTimeout, receiveTimeout: _kReceiveTimeout, - )); + ); + _dio = Dio(options); + // 갱신 전용 — 인터셉터를 두지 않아 갱신 요청이 다시 갱신을 부르지 않는다 + _authDio = Dio(options); + if (httpClientAdapter != null) { + _dio.httpClientAdapter = httpClientAdapter; + _authDio.httpClientAdapter = httpClientAdapter; + } _dio.interceptors.add( InterceptorsWrapper( - onRequest: (options, handler) async { - // Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착 - final token = await _storage.read(key: kJwtTokenKey); - if (token != null) { - options.headers['Authorization'] = 'Bearer $token'; - } + onRequest: (options, handler) { + // Token Storage 정책: 세션의 액세스 토큰을 헤더에 붙인다 (SecureStorage 는 세션이 들고 있다) + final token = _session.tokens?.accessToken; + if (token != null) options.headers['Authorization'] = 'Bearer $token'; return handler.next(options); }, onResponse: (response, handler) { final payload = response.data; - if (payload is Map && - payload.containsKey('success')) { + if (payload is Map && payload.containsKey('success')) { if (payload['success'] == true) { // Global Response Wrapper: 데이터 Unwrap response.data = payload['data']; @@ -52,14 +76,16 @@ class DioClient { // HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리) final error = _readErrorObject(payload); + final apiException = ApiException.fromCode( + error.code ?? 'BIZ_001', + traceId: error.traceId, + statusCode: response.statusCode, + ); + _report(response.requestOptions, apiException); return handler.reject( DioException( requestOptions: response.requestOptions, - error: ApiException.fromCode( - error.code ?? 'BIZ_001', - traceId: error.traceId, - statusCode: response.statusCode, - ), + error: apiException, type: DioExceptionType.badResponse, ), ); @@ -67,8 +93,18 @@ class DioClient { return handler.next(response); }, onError: (DioException e, handler) async { - final apiException = await _toApiException(e); + // 액세스 토큰이 만료됐으면 갱신해 한 번 다시 보낸다 + if (await _canRetryWithFreshToken(e)) { + try { + return handler.resolve(await _dio.fetch(_withFreshToken(e.requestOptions))); + } on DioException catch (retryError) { + // 다시 보낸 요청도 이 인터셉터를 지나며 정규화·알림까지 끝났다 + return handler.next(retryError); + } + } + final apiException = await _toApiException(e); + _report(e.requestOptions, apiException); // 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다. return handler.next(e.copyWith(error: apiException)); }, @@ -76,10 +112,68 @@ class DioClient { ); } + final SessionStore _session; + final ApiErrorReporter? _onError; + late final Dio _dio; + late final Dio _authDio; + + /// 진행 중인 토큰 갱신 — 동시에 여러 요청이 401 을 받아도 갱신은 한 번만 한다 + Future<_RefreshResult>? _refreshing; + + Dio get dio => _dio; + + /// 401 이고, 토큰을 붙여 보낸 요청이며, 아직 다시 보내지 않았으면 — 갱신(또는 이미 갱신된 토큰)으로 다시 보낸다 + Future _canRetryWithFreshToken(DioException e) async { + final options = e.requestOptions; + if (e.response?.statusCode != 401 || options.extra[_kRetriedExtra] == true) return false; + final sent = options.headers['Authorization']; + final tokens = _session.tokens; + if (sent == null || tokens == null) return false; + + // 다른 요청이 이미 갱신했으면 새 토큰으로 다시 보내기만 한다 + if (sent != 'Bearer ${tokens.accessToken}') return true; + + final result = await _refreshOnce(); + if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = true; + return result == _RefreshResult.refreshed; + } + + RequestOptions _withFreshToken(RequestOptions options) { + options.extra[_kRetriedExtra] = true; + final token = _session.tokens?.accessToken; + if (token != null) options.headers['Authorization'] = 'Bearer $token'; + return options; + } + + Future<_RefreshResult> _refreshOnce() => _refreshing ??= _refresh().whenComplete(() => _refreshing = null); + + /// 리프레시 토큰으로 새 한 쌍을 받는다. 서버가 거절하면(401) 세션을 끊는다 — + /// 네트워크 오류면 세션은 그대로 두고 이번 요청만 실패시킨다(다시 로그인할 일이 아니다). + Future<_RefreshResult> _refresh() async { + final tokens = _session.tokens; + if (tokens == null) return _RefreshResult.rejected; + try { + final response = await _authDio.post>( + kRefreshPath, + data: {'refreshToken': tokens.refreshToken}, + ); + final data = response.data?['data']; + if (data is! Map) return _RefreshResult.rejected; + await _session.save(AuthTokens.fromJson(data)); + return _RefreshResult.refreshed; + } on DioException catch (e) { + final response = e.response; + if (response == null || (response.statusCode ?? 0) >= 500) return _RefreshResult.unreachable; + return _RefreshResult.rejected; + } + } + /// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다. Future _toApiException(DioException e) async { - final response = e.response; + final existing = e.error; + if (existing is ApiException) return existing; + final response = e.response; if (response == null) { // 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우 return ApiException.fromCode('SYS_001'); @@ -96,11 +190,16 @@ class DioClient { code ??= _resolveCodeByStatus(response.statusCode); - // 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다. + // 토큰을 붙여 보냈는데 끝내 401 이면 "세션 만료"(AUTH_002) — 세션을 지우면 진입 상태가 로그인 화면으로 보낸다. + // 갱신하려다 서버에 닿지 못했으면 다시 로그인할 일이 아니므로 일시적 오류(SYS_001)로 둔다. // AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다. - if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) { - await _storage.delete(key: kJwtTokenKey); - code = 'AUTH_002'; + if (code == 'AUTH_001' && e.requestOptions.headers['Authorization'] != null) { + if (e.requestOptions.extra[_kRefreshUnreachableExtra] == true) { + code = 'SYS_001'; + } else { + if (_session.hasSession) await _session.clear(expired: true); + code = 'AUTH_002'; + } } return ApiException.fromCode( @@ -110,6 +209,13 @@ class DioClient { ); } + void _report(RequestOptions options, ApiException error) { + if (options.extra[kSilentExtra] == true) return; + final silentCodes = options.extra[kSilentCodesExtra]; + if (silentCodes is List && silentCodes.contains(error.code)) return; + _onError?.call(error); + } + /// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우) String _resolveCodeByStatus(int? statusCode) { switch (statusCode) { @@ -143,6 +249,20 @@ class DioClient { traceId: traceId is String ? traceId : null, ); } - - Dio get dio => _dio; +} + +/// 같은 에러 코드의 알림이 연달아 뜨지 않게 거른다 (앱을 열 때 여러 목록이 함께 실패하는 경우 등) +class ApiErrorThrottle { + ApiErrorThrottle({this.window = const Duration(seconds: 3)}); + + final Duration window; + final Map _lastShown = {}; + + /// [now] 에 [code] 알림을 띄워도 되는지 — 띄우면 그 시각을 기억한다 + bool allow(String code, DateTime now) { + final last = _lastShown[code]; + if (last != null && now.difference(last) < window) return false; + _lastShown[code] = now; + return true; + } } diff --git a/flutter/lib/core/api/dio_provider.dart b/flutter/lib/core/api/dio_provider.dart index 1771e8b..fdddece 100644 --- a/flutter/lib/core/api/dio_provider.dart +++ b/flutter/lib/core/api/dio_provider.dart @@ -1,9 +1,21 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/config/app_config.dart'; + import 'dio_client.dart'; +import 'session_store.dart'; + +/// 실패를 화면에 알리는 방법 — main() 에서 토스트로 바꿔 끼운다 (기본값은 아무것도 하지 않는다) +final apiErrorReporterProvider = Provider((ref) => (_) {}); + +/// 인터셉터(토큰 첨부·응답 언래핑·토큰 갱신·에러 매핑)가 적용된 클라이언트 +final dioClientProvider = Provider((ref) { + return DioClient( + baseUrl: ref.watch(apiBaseUrlProvider), + session: ref.watch(sessionStoreProvider), + onError: (error) => ref.read(apiErrorReporterProvider)(error), + ); +}); // 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다. -// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다. -final dioProvider = Provider((ref) { - return DioClient().dio; -}); +final dioProvider = Provider((ref) => ref.watch(dioClientProvider).dio); diff --git a/flutter/lib/core/api/file_uploader.dart b/flutter/lib/core/api/file_uploader.dart new file mode 100644 index 0000000..fbaef39 --- /dev/null +++ b/flutter/lib/core/api/file_uploader.dart @@ -0,0 +1,62 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:tma_app/core/api/api_exception.dart'; +import 'package:tma_app/core/api/dio_client.dart'; +import 'package:tma_app/core/api/dio_provider.dart'; +import 'package:tma_app/core/api/server_file.dart'; +import 'package:tma_app/core/utils/image_type.dart'; + +/// 파일 업로드 경로 (backend UploadController `POST /files`) +const String kFilesPath = '/files'; + +/// 기기 사진을 서버에 올린다 — 사진 경로 자리의 값을 서버 파일 id 로 바꾸는 일을 맡는다. +/// 같은 사진을 두 번 올리지 않도록, 이번 실행 동안 올린 경로를 기억한다. +class FileUploader { + FileUploader(this._dio, this._report); + + final Dio _dio; + final ApiErrorReporter _report; + final Map _uploaded = {}; + + /// 사진 경로 → 서버 파일 id + /// - null → null (사진 없음) + /// - 이미 서버 파일 → 그 id + /// - 기기 사진 → 올리고 받은 id + Future ensureUploaded(String? path) async { + if (path == null) return null; + final serverId = serverFileIdOf(path); + if (serverId != null) return serverId; + + final known = _uploaded[path]; + if (known != null) return known; + + final bytes = await XFile(path).readAsBytes(); + final type = detectImageType(bytes); + if (type == null) { + // 서버도 거절할 형식 — 보내지 않고 서버의 "형식 오류"(FILE_001)와 똑같이 알린다 + final error = ApiException.fromCode('FILE_001'); + _report(error); + throw error; + } + + final response = await _dio.post>( + kFilesPath, + data: FormData.fromMap({ + 'file': MultipartFile.fromBytes( + bytes, + // 원본 이름은 보내지 않는다 — 서버가 저장 이름을 정하고, 확장자만 내용과 맞춘다 + filename: 'photo.${type.extension}', + contentType: DioMediaType.parse(type.mimeType), + ), + }), + ); + final id = response.data!['id']! as String; + _uploaded[path] = id; + return id; + } +} + +final fileUploaderProvider = Provider( + (ref) => FileUploader(ref.watch(dioProvider), (error) => ref.read(apiErrorReporterProvider)(error)), +); diff --git a/flutter/lib/core/api/server_file.dart b/flutter/lib/core/api/server_file.dart new file mode 100644 index 0000000..9605766 --- /dev/null +++ b/flutter/lib/core/api/server_file.dart @@ -0,0 +1,20 @@ +// 사진 경로 표기 — 기기 사진 경로와 서버 파일을 같은 문자열 자리(photoPath 등)에 담는다. + +/// 서버에 올린 파일 표시 — `server-file:<파일 id>` +const String kServerFilePrefix = 'server-file:'; + +/// 앱에 들어 있는 그림 (명함 기본 배경 등) +const String kBundledAssetPrefix = 'assets/'; + +/// 서버 파일 id → 사진 경로 자리에 넣을 값 +String serverFileRef(String fileId) => '$kServerFilePrefix$fileId'; + +/// 서버 파일이면 그 id, 아니면 null +String? serverFileIdOf(String? path) => + path != null && path.startsWith(kServerFilePrefix) ? path.substring(kServerFilePrefix.length) : null; + +/// 서버 파일 id(또는 null) → 사진 경로 자리에 넣을 값(또는 null) +String? serverFileRefOrNull(String? fileId) => fileId == null ? null : serverFileRef(fileId); + +/// 앱에 들어 있는 그림인지 +bool isBundledAsset(String path) => path.startsWith(kBundledAssetPrefix); diff --git a/flutter/lib/core/api/session_store.dart b/flutter/lib/core/api/session_store.dart new file mode 100644 index 0000000..9fa3160 --- /dev/null +++ b/flutter/lib/core/api/session_store.dart @@ -0,0 +1,114 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다. +const String kJwtTokenKey = 'jwt_token'; + +/// 리프레시 토큰 저장 키. +/// 웹은 HttpOnly 쿠키로 받지만, 모바일 앱에는 쿠키 저장소가 없어 secure storage 에 둔다 (.claude/rules/security.md). +const String kRefreshTokenKey = 'refresh_token'; + +/// 로그인 토큰 한 쌍 — 액세스(짧게) + 리프레시(한 번 쓰면 새로 받는다) +@immutable +class AuthTokens { + const AuthTokens({required this.accessToken, required this.refreshToken}); + + final String accessToken; + final String refreshToken; + + /// 서버 로그인·갱신 응답(data)에서 꺼낸다 + factory AuthTokens.fromJson(Map json) => AuthTokens( + accessToken: json['accessToken']! as String, + refreshToken: json['refreshToken']! as String, + ); +} + +/// 토큰을 기기에 보관하는 곳 +abstract interface class TokenVault { + Future read(); + + Future write(AuthTokens tokens); + + Future clear(); +} + +/// flutter_secure_storage — 운영 앱의 보관소 (SharedPreferences 에 토큰을 두지 않는다) +class SecureTokenVault implements TokenVault { + const SecureTokenVault([this._storage = const FlutterSecureStorage()]); + + final FlutterSecureStorage _storage; + + @override + Future read() async { + final access = await _storage.read(key: kJwtTokenKey); + final refresh = await _storage.read(key: kRefreshTokenKey); + if (access == null || refresh == null) return null; + return AuthTokens(accessToken: access, refreshToken: refresh); + } + + @override + Future write(AuthTokens tokens) async { + await _storage.write(key: kJwtTokenKey, value: tokens.accessToken); + await _storage.write(key: kRefreshTokenKey, value: tokens.refreshToken); + } + + @override + Future clear() async { + await _storage.delete(key: kJwtTokenKey); + await _storage.delete(key: kRefreshTokenKey); + } +} + +/// 메모리 보관소 — 테스트·기기 저장 모드 +class MemoryTokenVault implements TokenVault { + AuthTokens? _tokens; + + @override + Future read() async => _tokens; + + @override + Future write(AuthTokens tokens) async => _tokens = tokens; + + @override + Future clear() async => _tokens = null; +} + +/// 지금 로그인 세션 — 토큰을 메모리에 들고, 바뀌면 기기 보관소에도 쓴다. +/// 서버가 세션을 끊으면(갱신 실패) [expired] 가 켜지고 리스너(진입 상태)가 로그인 화면으로 보낸다. +class SessionStore extends ChangeNotifier { + SessionStore(this._vault, [this._tokens]); + + /// 앱을 열 때 — 첫 화면을 정하기 전에 보관한 토큰을 읽어 둔다 + static Future load(TokenVault vault) async => SessionStore(vault, await vault.read()); + + final TokenVault _vault; + AuthTokens? _tokens; + bool _expired = false; + + AuthTokens? get tokens => _tokens; + + bool get hasSession => _tokens != null; + + /// 서버가 세션을 끊어 로그아웃됐는지 (AUTH_002) + bool get expired => _expired; + + Future save(AuthTokens tokens) async { + _tokens = tokens; + _expired = false; + notifyListeners(); + await _vault.write(tokens); + } + + /// 세션을 지운다 — [expired] 는 사용자가 아니라 서버가 끊은 경우 + Future clear({bool expired = false}) async { + final had = _tokens != null; + _tokens = null; + _expired = expired && had; + notifyListeners(); + await _vault.clear(); + } +} + +/// main() 에서 보관한 토큰을 읽은 세션으로 override 한다. 기본값은 빈 메모리 세션(테스트). +final sessionStoreProvider = Provider((ref) => SessionStore(MemoryTokenVault())); diff --git a/flutter/lib/core/config/app_config.dart b/flutter/lib/core/config/app_config.dart new file mode 100644 index 0000000..451c81c --- /dev/null +++ b/flutter/lib/core/config/app_config.dart @@ -0,0 +1,23 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 기록을 어디에 두는지 +/// - [api]: 서버 (기본값 — 로그인한 회원의 기록을 어느 기기에서나 본다) +/// - [local]: 이 기기 (서버 없이 화면만 볼 때 · 테스트) +enum DataMode { api, local } + +/// `.env` 에 API_URL 이 없을 때 — 호스트에서 띄운 개발 서버 (백엔드 글로벌 prefix /api 포함) +const String kDefaultApiUrl = 'http://localhost:3000/api'; + +/// `.env` 의 DATA_MODE (api | local). `.env` 를 읽지 않았으면(테스트) 기기 저장으로 둔다. +DataMode readDataMode() { + if (!dotenv.isInitialized) return DataMode.local; + return dotenv.maybeGet('DATA_MODE') == DataMode.local.name ? DataMode.local : DataMode.api; +} + +/// `.env` 의 API_URL — 안드로이드 에뮬레이터에서 호스트에 붙을 때는 http://10.0.2.2:3000/api +String readApiBaseUrl() => (dotenv.isInitialized ? dotenv.maybeGet('API_URL') : null) ?? kDefaultApiUrl; + +final dataModeProvider = Provider((ref) => readDataMode()); + +final apiBaseUrlProvider = Provider((ref) => readApiBaseUrl()); diff --git a/flutter/lib/core/utils/image_type.dart b/flutter/lib/core/utils/image_type.dart new file mode 100644 index 0000000..e799dd2 --- /dev/null +++ b/flutter/lib/core/utils/image_type.dart @@ -0,0 +1,32 @@ +import 'dart:typed_data'; + +/// 사진 형식 — 올릴 때 파일 이름의 확장자와 MIME 을 정한다 (서버는 확장자·MIME·내용을 함께 확인한다) +enum ImageType { + jpeg('jpg', 'image/jpeg'), + png('png', 'image/png'), + gif('gif', 'image/gif'), + webp('webp', 'image/webp'); + + const ImageType(this.extension, this.mimeType); + + final String extension; + final String mimeType; +} + +bool _startsWith(Uint8List bytes, List prefix, [int offset = 0]) { + if (bytes.length < offset + prefix.length) return false; + for (var i = 0; i < prefix.length; i++) { + if (bytes[offset + i] != prefix[i]) return false; + } + return true; +} + +/// 파일 앞부분(매직 바이트)으로 사진 형식을 알아낸다 — 모르면 null. +/// 웹은 사진 경로가 blob 주소라 확장자를 알 수 없어 내용으로 정한다. +ImageType? detectImageType(Uint8List bytes) { + if (_startsWith(bytes, const [0xFF, 0xD8, 0xFF])) return ImageType.jpeg; + if (_startsWith(bytes, const [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) return ImageType.png; + if (_startsWith(bytes, 'GIF87a'.codeUnits) || _startsWith(bytes, 'GIF89a'.codeUnits)) return ImageType.gif; + if (_startsWith(bytes, 'RIFF'.codeUnits) && _startsWith(bytes, 'WEBP'.codeUnits, 8)) return ImageType.webp; + return null; +} diff --git a/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart b/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart index 6dc54b0..bd0e15f 100644 --- a/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart +++ b/flutter/lib/features/pet/presentation/widgets/pet_photo_image.dart @@ -1,9 +1,7 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:tma_app/shared/widgets/photo_image.dart'; -/// 기기에 저장된 반려동물 사진 — 웹 미리보기에서는 브라우저 임시 주소를 쓴다 +/// 반려동물·다이어리 사진 — 서버 파일 또는 기기 사진. 읽을 수 없으면 빈 칸으로 둔다 class PetPhotoImage extends StatelessWidget { const PetPhotoImage({super.key, required this.path, this.fit = BoxFit.cover}); @@ -11,12 +9,5 @@ class PetPhotoImage extends StatelessWidget { final BoxFit fit; @override - Widget build(BuildContext context) { - // 사진 파일이 지워졌거나 읽을 수 없으면 빈 칸으로 둔다 - Widget error(BuildContext context, Object e, StackTrace? s) => const SizedBox.shrink(); - if (kIsWeb) { - return Image.network(path, fit: fit, errorBuilder: error); - } - return Image.file(File(path), fit: fit, errorBuilder: error); - } + Widget build(BuildContext context) => PhotoImage(path: path, fit: fit); } diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 2fa5a00..e487258 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -4,7 +4,11 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tma_app/app.dart'; +import 'package:tma_app/core/api/dio_provider.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/core/config/app_config.dart'; import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -21,10 +25,18 @@ Future main() async { // 진입 화면 결정에 필요한 저장값을 첫 프레임 전에 읽어 둔다 final prefs = await SharedPreferences.getInstance(); + // 로그인 세션(토큰) — 서버 모드에서만 secure storage 를 쓴다 + final session = readDataMode() == DataMode.api + ? await SessionStore.load(const SecureTokenVault()) + : SessionStore(MemoryTokenVault()); runApp( ProviderScope( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + sessionStoreProvider.overrideWithValue(session), + apiErrorReporterProvider.overrideWith(apiErrorToastReporter), + ], child: const RupApp(), ), ); diff --git a/flutter/lib/shared/widgets/person_avatar.dart b/flutter/lib/shared/widgets/person_avatar.dart index 247f2a9..572e07c 100644 --- a/flutter/lib/shared/widgets/person_avatar.dart +++ b/flutter/lib/shared/widgets/person_avatar.dart @@ -1,10 +1,8 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tma_app/core/constants/app_assets.dart'; import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/shared/widgets/photo_image.dart'; /// 사람(보호자) 원형 아바타 — 사진이 없으면 기본 사람 아이콘 class PersonAvatar extends StatelessWidget { @@ -19,18 +17,14 @@ class PersonAvatar extends StatelessWidget { @override Widget build(BuildContext context) { final photo = photoPath; - Widget error(BuildContext c, Object e, StackTrace? s) => - SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover); + final placeholder = SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover); return Container( width: size, height: size, decoration: BoxDecoration(color: background, shape: BoxShape.circle), clipBehavior: Clip.antiAlias, - child: photo == null - ? SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover) - : kIsWeb - ? Image.network(photo, fit: BoxFit.cover, errorBuilder: error) - : Image.file(File(photo), fit: BoxFit.cover, errorBuilder: error), + // 서버 파일·기기 사진 모두 — 읽을 수 없으면 기본 사람 아이콘 + child: photo == null ? placeholder : PhotoImage(path: photo, fallback: placeholder), ); } } diff --git a/flutter/lib/shared/widgets/photo_image.dart b/flutter/lib/shared/widgets/photo_image.dart new file mode 100644 index 0000000..fb02fa3 --- /dev/null +++ b/flutter/lib/shared/widgets/photo_image.dart @@ -0,0 +1,73 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/dio_client.dart'; +import 'package:tma_app/core/api/dio_provider.dart'; +import 'package:tma_app/core/api/file_uploader.dart'; +import 'package:tma_app/core/api/server_file.dart'; + +/// 서버에 올린 사진 — 앱의 HTTP 클라이언트(Dio)로 받아 토큰 첨부·갱신을 그대로 쓴다. +/// (Image.network 에 토큰을 직접 붙이면 액세스 토큰이 만료된 뒤 사진만 깨진다) +@immutable +class ServerFileImage extends ImageProvider { + const ServerFileImage(this.fileId, this.dio); + + final String fileId; + final Dio dio; + + @override + Future obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage(ServerFileImage key, ImageDecoderCallback decode) { + return MultiFrameImageStreamCompleter(codec: _load(decode), scale: 1); + } + + Future _load(ImageDecoderCallback decode) async { + final response = await dio.get>( + '$kFilesPath/$fileId', + // 사진 하나가 안 보여도 알림을 띄우지 않는다 — 화면은 빈칸(기본 그림)으로 둔다 + options: Options(responseType: ResponseType.bytes, extra: {kSilentExtra: true}), + ); + final buffer = await ui.ImmutableBuffer.fromUint8List(Uint8List.fromList(response.data!)); + return decode(buffer); + } + + /// 파일 id 가 같으면 같은 사진이다 — 서버 파일은 id 별로 내용이 바뀌지 않는다 + @override + bool operator ==(Object other) => other is ServerFileImage && other.fileId == fileId; + + @override + int get hashCode => fileId.hashCode; +} + +/// 사진 한 장 — 서버 파일 · 기기 파일 · 웹 미리보기(브라우저 임시 주소)를 같은 자리에 그린다. +/// 읽을 수 없으면 [fallback] 을 그린다. +class PhotoImage extends ConsumerWidget { + const PhotoImage({ + super.key, + required this.path, + this.fit = BoxFit.cover, + this.fallback = const SizedBox.shrink(), + }); + + final String path; + final BoxFit fit; + final Widget fallback; + + @override + Widget build(BuildContext context, WidgetRef ref) { + Widget error(BuildContext context, Object e, StackTrace? s) => fallback; + + final fileId = serverFileIdOf(path); + if (fileId != null) { + return Image(image: ServerFileImage(fileId, ref.watch(dioProvider)), fit: fit, errorBuilder: error); + } + if (kIsWeb) return Image.network(path, fit: fit, errorBuilder: error); + return Image.file(File(path), fit: fit, errorBuilder: error); + } +} diff --git a/flutter/lib/shared/widgets/rup_toast.dart b/flutter/lib/shared/widgets/rup_toast.dart index bd081a5..34b5805 100644 --- a/flutter/lib/shared/widgets/rup_toast.dart +++ b/flutter/lib/shared/widgets/rup_toast.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/dio_client.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/theme/app_shadows.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; @@ -139,3 +140,13 @@ class _ToastBubble extends StatelessWidget { ); } } + +/// API 실패를 토스트로 알린다 — 문구는 에러 사전에서만 온다. 같은 코드는 잠깐 동안 한 번만 띄운다. +/// main() 에서 `apiErrorReporterProvider.overrideWith(apiErrorToastReporter)` 로 끼운다. +ApiErrorReporter apiErrorToastReporter(Ref ref) { + final throttle = ApiErrorThrottle(); + return (error) { + if (!throttle.allow(error.code, DateTime.now())) return; + ref.read(toastProvider.notifier).show(error.message, icon: RupIconName.alert); + }; +} diff --git a/flutter/test/api_client_test.dart b/flutter/test/api_client_test.dart new file mode 100644 index 0000000..3bde12f --- /dev/null +++ b/flutter/test/api_client_test.dart @@ -0,0 +1,219 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/core/api/api_exception.dart'; +import 'package:tma_app/core/api/dio_client.dart'; +import 'package:tma_app/core/api/session_store.dart'; + +/// 요청 하나에 대한 가짜 응답 — 상태 코드와 JSON 본문 +typedef FakeReply = ({int status, Object? body}); + +/// 네트워크 없이 응답을 정해 주는 어댑터. 받은 요청을 기록한다. +class FakeAdapter implements HttpClientAdapter { + FakeAdapter(this.reply); + + final FutureOr Function(RequestOptions options) reply; + final List requests = []; + + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) async { + requests.add(options); + final r = await reply(options); + return ResponseBody.fromString( + jsonEncode(r.body), + r.status, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +const _oldTokens = AuthTokens(accessToken: 'old-access', refreshToken: 'old-refresh'); + +Map _ok(Object? data) => {'success': true, 'data': data}; + +Map _fail(String code) => { + 'success': false, + 'error': {'code': code, 'message': '서버 문구', 'traceId': 'trace-1'}, + }; + +Map _newTokens([String suffix = '1']) => + _ok({'accessToken': 'new-access-$suffix', 'refreshToken': 'new-refresh-$suffix'}); + +String? _auth(RequestOptions o) => o.headers['Authorization'] as String?; + +void main() { + late List reported; + + setUp(() => reported = []); + + ({DioClient client, FakeAdapter adapter, SessionStore session}) build( + FutureOr Function(RequestOptions o) reply, { + AuthTokens? tokens = _oldTokens, + }) { + final adapter = FakeAdapter(reply); + final session = SessionStore(MemoryTokenVault(), tokens); + final client = DioClient( + baseUrl: 'http://test/api', + session: session, + onError: reported.add, + httpClientAdapter: adapter, + ); + return (client: client, adapter: adapter, session: session); + } + + Future errorOf(Future request) async { + try { + await request; + } on DioException catch (e) { + return e.error! as ApiException; + } + throw StateError('요청이 실패하지 않았다'); + } + + test('성공 응답은 data 만 꺼내 주고, 액세스 토큰을 붙여 보낸다', () async { + final t = build((o) => (status: 200, body: _ok({'id': 'pet-1'}))); + + final res = await t.client.dio.get>('/pets/1'); + + expect(res.data, {'id': 'pet-1'}); + expect(_auth(t.adapter.requests.single), 'Bearer old-access'); + }); + + test('실패는 서버의 에러 코드로 정규화되고 문구는 사전에서 온다', () async { + final t = build((o) => (status: 404, body: _fail('RES_001'))); + + final error = await errorOf(t.client.dio.get('/pets/1')); + + expect(error.code, 'RES_001'); + expect(error.traceId, 'trace-1'); + expect(error.message, isNot('서버 문구')); + expect(reported.map((e) => e.code), ['RES_001']); + }); + + test('액세스 토큰이 만료되면 갱신하고 같은 요청을 새 토큰으로 다시 보낸다', () async { + final t = build((o) { + if (o.path == kRefreshPath) return (status: 201, body: _newTokens()); + return _auth(o) == 'Bearer new-access-1' + ? (status: 200, body: _ok('done')) + : (status: 401, body: _fail('AUTH_001')); + }); + + final res = await t.client.dio.get('/users/me'); + + expect(res.data, 'done'); + expect(t.session.tokens?.accessToken, 'new-access-1'); + expect(t.session.tokens?.refreshToken, 'new-refresh-1'); + final refresh = t.adapter.requests.where((o) => o.path == kRefreshPath).single; + expect(refresh.data, {'refreshToken': 'old-refresh'}); + // 갱신 요청에는 만료된 액세스 토큰을 붙이지 않는다 + expect(_auth(refresh), isNull); + expect(reported, isEmpty); + }); + + test('여러 요청이 함께 401 을 받아도 갱신은 한 번만 한다', () async { + var refreshCalls = 0; + final t = build((o) async { + if (o.path == kRefreshPath) { + refreshCalls++; + await Future.delayed(const Duration(milliseconds: 20)); + return (status: 201, body: _newTokens()); + } + return _auth(o) == 'Bearer new-access-1' + ? (status: 200, body: _ok(o.path)) + : (status: 401, body: _fail('AUTH_001')); + }); + + final results = await Future.wait([ + t.client.dio.get('/pets'), + t.client.dio.get('/diaries'), + t.client.dio.get('/pet-cards'), + ]); + + expect(results.map((r) => r.data), ['/pets', '/diaries', '/pet-cards']); + expect(refreshCalls, 1); + }); + + test('서버가 리프레시 토큰을 거절하면 세션을 끊고 AUTH_002(세션 만료)로 알린다', () async { + final t = build((o) => o.path == kRefreshPath + ? (status: 401, body: _fail('AUTH_001')) + : (status: 401, body: _fail('AUTH_001'))); + var notified = 0; + t.session.addListener(() => notified++); + + final error = await errorOf(t.client.dio.get('/pets')); + + expect(error.code, 'AUTH_002'); + expect(t.session.hasSession, isFalse); + expect(t.session.expired, isTrue); + expect(notified, greaterThan(0)); + expect(reported.map((e) => e.code), ['AUTH_002']); + }); + + test('갱신하려다 서버에 닿지 못하면 세션은 두고 일시적 오류(SYS_001)로 알린다', () async { + final t = build((o) { + if (o.path == kRefreshPath) throw const SocketExceptionLike(); + return (status: 401, body: _fail('AUTH_001')); + }); + + final error = await errorOf(t.client.dio.get('/pets')); + + expect(error.code, 'SYS_001'); + expect(t.session.hasSession, isTrue); + }); + + test('로그인 전(토큰 없음)의 401 은 그대로 AUTH_001 이다', () async { + final t = build((o) => (status: 401, body: _fail('AUTH_001')), tokens: null); + + final error = await errorOf(t.client.dio.post('/auth/sessions')); + + expect(error.code, 'AUTH_001'); + expect(t.adapter.requests.where((o) => o.path == kRefreshPath), isEmpty); + }); + + test('호출한 쪽이 직접 처리하는 코드는 전역 알림을 띄우지 않는다', () async { + final t = build((o) => (status: 409, body: _fail('DIARY_001'))); + + final error = await errorOf(t.client.dio.post( + '/diaries', + options: Options(extra: { + kSilentCodesExtra: ['DIARY_001'], + }), + )); + + expect(error.code, 'DIARY_001'); + expect(reported, isEmpty); + }); + + test('네트워크가 끊기면 SYS_001 이다', () async { + final t = build((o) => throw const SocketExceptionLike()); + + final error = await errorOf(t.client.dio.get('/pets')); + + expect(error.code, 'SYS_001'); + }); + + group('ApiErrorThrottle', () { + test('같은 코드는 잠깐 동안 한 번만, 다른 코드는 따로 띄운다', () { + final throttle = ApiErrorThrottle(window: const Duration(seconds: 3)); + final t0 = DateTime(2026, 10, 11, 9); + + expect(throttle.allow('SYS_001', t0), isTrue); + expect(throttle.allow('SYS_001', t0.add(const Duration(seconds: 1))), isFalse); + expect(throttle.allow('RES_001', t0.add(const Duration(seconds: 1))), isTrue); + expect(throttle.allow('SYS_001', t0.add(const Duration(seconds: 4))), isTrue); + }); + }); +} + +/// 어댑터에서 연결 실패를 흉내 낸다 — Dio 는 응답 없는 DioException 으로 감싼다 +class SocketExceptionLike implements Exception { + const SocketExceptionLike(); +} diff --git a/flutter/test/server_file_test.dart b/flutter/test/server_file_test.dart new file mode 100644 index 0000000..d2b23f9 --- /dev/null +++ b/flutter/test/server_file_test.dart @@ -0,0 +1,48 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/core/api/server_file.dart'; +import 'package:tma_app/core/utils/image_type.dart'; + +void main() { + group('사진 경로 표기', () { + test('서버 파일은 server-file: 접두어로 담고 id 를 다시 꺼낸다', () { + final ref = serverFileRef('abc'); + + expect(ref, 'server-file:abc'); + expect(serverFileIdOf(ref), 'abc'); + expect(serverFileRefOrNull(null), isNull); + }); + + test('기기 사진·앱 그림은 서버 파일이 아니다', () { + expect(serverFileIdOf('/data/user/0/app/pet_photos/1.jpg'), isNull); + expect(serverFileIdOf('assets/svg/card_bg/bg_1.svg'), isNull); + expect(serverFileIdOf(null), isNull); + expect(isBundledAsset('assets/svg/card_bg/bg_1.svg'), isTrue); + expect(isBundledAsset('/tmp/a.png'), isFalse); + }); + }); + + group('사진 형식 알아내기', () { + test('파일 앞부분으로 형식을 정한다', () { + expect(detectImageType(Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0])), ImageType.jpeg); + expect( + detectImageType(Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0])), + ImageType.png, + ); + expect(detectImageType(Uint8List.fromList('GIF89a..'.codeUnits)), ImageType.gif); + expect(detectImageType(Uint8List.fromList('RIFF\x00\x00\x00\x00WEBP'.codeUnits)), ImageType.webp); + }); + + test('사진이 아니면 null — 보내지 않는다', () { + expect(detectImageType(Uint8List.fromList(''.codeUnits)), isNull); + expect(detectImageType(Uint8List(0)), isNull); + }); + + test('확장자와 MIME 이 서버 허용 목록과 맞는다', () { + expect(ImageType.jpeg.extension, 'jpg'); + expect(ImageType.jpeg.mimeType, 'image/jpeg'); + expect(ImageType.webp.mimeType, 'image/webp'); + }); + }); +} diff --git a/flutter/test/support/app_harness.dart b/flutter/test/support/app_harness.dart index a50054f..702a09a 100644 --- a/flutter/test/support/app_harness.dart +++ b/flutter/test/support/app_harness.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tma_app/app.dart'; +import 'package:tma_app/core/config/app_config.dart'; import 'package:tma_app/core/storage/preferences_provider.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; @@ -43,7 +44,12 @@ Future launchSignedIn( final prefs = await SharedPreferences.getInstance(); await tester.pumpWidget( ProviderScope( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs), ...overrides], + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + // 흐름 테스트는 기기 저장소로 돈다 — 서버 저장소는 api_client_test·api_repository_test 가 맡는다 + dataModeProvider.overrideWithValue(DataMode.local), + ...overrides, + ], child: const RupApp(), ), );