first commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'error_lexicon.dart';
|
||||
|
||||
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(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
|
||||
final token = await _storage.read(key: 'jwt_token');
|
||||
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['success'] == true) {
|
||||
// Global Response Wrapper: 데이터 Unwrap
|
||||
response.data = payload['data'];
|
||||
return handler.next(response);
|
||||
} else {
|
||||
// HTTP 200 OK 하에서의 비즈니스 실패 로직
|
||||
final errorCode = payload['error']?['code'] ?? 'BIZ_001';
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
error: ErrorLexicon.getMessage(errorCode),
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return handler.next(response);
|
||||
},
|
||||
onError: (DioException e, handler) {
|
||||
String errorCode = 'SYS_001';
|
||||
|
||||
if (e.response != null && e.response?.data is Map<String, dynamic>) {
|
||||
final payload = e.response!.data as Map<String, dynamic>;
|
||||
|
||||
// 1. 서버에서 표준 에러 객체를 보낸 경우
|
||||
if (payload['error'] != null && payload['error']['code'] != null) {
|
||||
errorCode = payload['error']['code'];
|
||||
} else {
|
||||
// 2. 서버 에러 객체가 없는 경우 HTTP 상태 코드 기반 매핑
|
||||
final statusCode = e.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
errorCode = 'AUTH_001';
|
||||
} else if (statusCode == 403) {
|
||||
errorCode = 'AUTH_003';
|
||||
} else if (statusCode == 400 || statusCode == 422) {
|
||||
errorCode = 'VAL_001';
|
||||
} else if (statusCode == 404) {
|
||||
errorCode = 'RES_001';
|
||||
}
|
||||
}
|
||||
} else if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
errorCode = 'SYS_001';
|
||||
}
|
||||
|
||||
final errorMessage = ErrorLexicon.getMessage(errorCode);
|
||||
|
||||
// User-Friendly Error UI를 위해 에러 메시지 덮어쓰기
|
||||
final customError = e.copyWith(error: errorMessage);
|
||||
return handler.next(customError);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class ErrorLexicon {
|
||||
static const Map<String, String> codes = {
|
||||
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
};
|
||||
|
||||
static String getMessage(String code) {
|
||||
return codes[code] ?? codes['SYS_001']!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Empty directory placeholder
|
||||
@@ -0,0 +1 @@
|
||||
// Empty directory placeholder
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
await dotenv.load(fileName: '.env');
|
||||
} catch (_) {
|
||||
// .env 파일이 없는 경우 무시 (기본값 사용)
|
||||
}
|
||||
|
||||
runApp(
|
||||
// Riverpod Provider Scope 적용
|
||||
const ProviderScope(
|
||||
child: MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'TMA App',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeView extends StatelessWidget {
|
||||
const HomeView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('TMA Global Architecture'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Riverpod & Dio Interceptor Setup Complete.'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')),
|
||||
);
|
||||
},
|
||||
child: const Text('Show Global Alert Test'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Empty directory placeholder
|
||||
Reference in New Issue
Block a user