generated from ttipo/project-base
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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()));
|
||||
@@ -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());
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user