feat(flutter): 서버 연동 기반 — 세션·토큰 갱신·사진 업로드·서버 사진 표시

기기 저장소를 API 로 바꾸기 전에, 모든 도메인이 함께 쓸 네트워크 계층을 먼저 갖춘다.

- DATA_MODE(api|local): 기본은 서버. .env 를 읽지 않는 테스트·화면 확인은 기기 저장소로 돈다
- SessionStore: 액세스·리프레시 토큰을 secure storage 에 두고 앱을 열 때 미리 읽는다
  (모바일에는 쿠키 저장소가 없어 리프레시 토큰도 secure storage 에 둔다)
- DioClient: 401 이면 토큰을 한 번 갱신해 다시 보낸다(동시 요청도 갱신은 한 번).
  서버가 갱신을 거절하면 세션을 끊고 AUTH_002, 서버에 닿지 못하면 세션은 두고 SYS_001
- 실패 알림은 사전 문구로 토스트 한 곳에서 띄우고 같은 코드는 잠깐 한 번만 — 호출부가 처리하는 코드는 끌 수 있다
- 사진: 경로 자리에 server-file:<id> 를 담고, 기기 사진은 저장할 때 형식을 내용으로 확인해 올린다.
  서버 사진은 Dio 로 받아 토큰 갱신을 그대로 쓴다 (PetPhotoImage·PersonAvatar)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:07:49 +09:00
parent 607ff5c474
commit cdd4856300
16 changed files with 836 additions and 67 deletions
+32
View File
@@ -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<List<T>> fetchAllPages<T>(
Dio dio,
String path,
T Function(Map<String, Object?> json) parse,
) async {
final items = <T>[];
String? cursor;
for (var page = 0; page < _kMaxPages; page++) {
final response = await dio.get<Map<String, dynamic>>(
path,
queryParameters: {'limit': kApiPageLimit, 'cursor': ?cursor},
);
final data = response.data!;
for (final item in data['items']! as List<Object?>) {
items.add(parse(item! as Map<String, Object?>));
}
final pageInfo = data['pageInfo']! as Map<String, Object?>;
cursor = pageInfo['nextCursor'] as String?;
if (pageInfo['hasNext'] != true || cursor == null) break;
}
return items;
}
+159 -39
View File
@@ -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<String, dynamic> &&
payload.containsKey('success')) {
if (payload is Map<String, dynamic> && 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<dynamic>(_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<bool> _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<Map<String, dynamic>>(
kRefreshPath,
data: {'refreshToken': tokens.refreshToken},
);
final data = response.data?['data'];
if (data is! Map<String, dynamic>) 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<ApiException> _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<String, DateTime> _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;
}
}
+16 -4
View File
@@ -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<ApiErrorReporter>((ref) => (_) {});
/// 인터셉터(토큰 첨부·응답 언래핑·토큰 갱신·에러 매핑)가 적용된 클라이언트
final dioClientProvider = Provider<DioClient>((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<Dio>((ref) {
return DioClient().dio;
});
final dioProvider = Provider<Dio>((ref) => ref.watch(dioClientProvider).dio);
+62
View File
@@ -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<String, String> _uploaded = {};
/// 사진 경로 → 서버 파일 id
/// - null → null (사진 없음)
/// - 이미 서버 파일 → 그 id
/// - 기기 사진 → 올리고 받은 id
Future<String?> 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<Map<String, dynamic>>(
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<FileUploader>(
(ref) => FileUploader(ref.watch(dioProvider), (error) => ref.read(apiErrorReporterProvider)(error)),
);
+20
View File
@@ -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);
+114
View File
@@ -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<String, Object?> json) => AuthTokens(
accessToken: json['accessToken']! as String,
refreshToken: json['refreshToken']! as String,
);
}
/// 토큰을 기기에 보관하는 곳
abstract interface class TokenVault {
Future<AuthTokens?> read();
Future<void> write(AuthTokens tokens);
Future<void> clear();
}
/// flutter_secure_storage — 운영 앱의 보관소 (SharedPreferences 에 토큰을 두지 않는다)
class SecureTokenVault implements TokenVault {
const SecureTokenVault([this._storage = const FlutterSecureStorage()]);
final FlutterSecureStorage _storage;
@override
Future<AuthTokens?> 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<void> write(AuthTokens tokens) async {
await _storage.write(key: kJwtTokenKey, value: tokens.accessToken);
await _storage.write(key: kRefreshTokenKey, value: tokens.refreshToken);
}
@override
Future<void> clear() async {
await _storage.delete(key: kJwtTokenKey);
await _storage.delete(key: kRefreshTokenKey);
}
}
/// 메모리 보관소 — 테스트·기기 저장 모드
class MemoryTokenVault implements TokenVault {
AuthTokens? _tokens;
@override
Future<AuthTokens?> read() async => _tokens;
@override
Future<void> write(AuthTokens tokens) async => _tokens = tokens;
@override
Future<void> clear() async => _tokens = null;
}
/// 지금 로그인 세션 — 토큰을 메모리에 들고, 바뀌면 기기 보관소에도 쓴다.
/// 서버가 세션을 끊으면(갱신 실패) [expired] 가 켜지고 리스너(진입 상태)가 로그인 화면으로 보낸다.
class SessionStore extends ChangeNotifier {
SessionStore(this._vault, [this._tokens]);
/// 앱을 열 때 — 첫 화면을 정하기 전에 보관한 토큰을 읽어 둔다
static Future<SessionStore> 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<void> save(AuthTokens tokens) async {
_tokens = tokens;
_expired = false;
notifyListeners();
await _vault.write(tokens);
}
/// 세션을 지운다 — [expired] 는 사용자가 아니라 서버가 끊은 경우
Future<void> clear({bool expired = false}) async {
final had = _tokens != null;
_tokens = null;
_expired = expired && had;
notifyListeners();
await _vault.clear();
}
}
/// main() 에서 보관한 토큰을 읽은 세션으로 override 한다. 기본값은 빈 메모리 세션(테스트).
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore(MemoryTokenVault()));
+23
View File
@@ -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<DataMode>((ref) => readDataMode());
final apiBaseUrlProvider = Provider<String>((ref) => readApiBaseUrl());
+32
View File
@@ -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<int> 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;
}
@@ -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);
}
+13 -1
View File
@@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -21,10 +25,18 @@ Future<void> 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(),
),
);
+4 -10
View File
@@ -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),
);
}
}
@@ -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<ServerFileImage> {
const ServerFileImage(this.fileId, this.dio);
final String fileId;
final Dio dio;
@override
Future<ServerFileImage> obtainKey(ImageConfiguration configuration) => SynchronousFuture(this);
@override
ImageStreamCompleter loadImage(ServerFileImage key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter(codec: _load(decode), scale: 1);
}
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
final response = await dio.get<List<int>>(
'$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);
}
}
+11
View File
@@ -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);
};
}
+219
View File
@@ -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<FakeReply> Function(RequestOptions options) reply;
final List<RequestOptions> requests = [];
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future<void>? 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<String, Object?> _ok(Object? data) => {'success': true, 'data': data};
Map<String, Object?> _fail(String code) => {
'success': false,
'error': {'code': code, 'message': '서버 문구', 'traceId': 'trace-1'},
};
Map<String, Object?> _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<ApiException> reported;
setUp(() => reported = []);
({DioClient client, FakeAdapter adapter, SessionStore session}) build(
FutureOr<FakeReply> 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<ApiException> errorOf(Future<Object?> 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<Map<String, dynamic>>('/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<Object?>('/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<Object?>('/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<void>.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<Object?>('/pets'),
t.client.dio.get<Object?>('/diaries'),
t.client.dio.get<Object?>('/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<Object?>('/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<Object?>('/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<Object?>('/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<Object?>(
'/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<Object?>('/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();
}
+48
View File
@@ -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('<html>'.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');
});
});
}
+7 -1
View File
@@ -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<SharedPreferences> 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(),
),
);