first commit

This commit is contained in:
2026-06-11 18:19:26 +09:00
commit eaa30192cf
291 changed files with 47278 additions and 0 deletions
+95
View File
@@ -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;
}
+15
View File
@@ -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']!;
}
}
+1
View File
@@ -0,0 +1 @@
// Empty directory placeholder
+1
View File
@@ -0,0 +1 @@
// Empty directory placeholder
+67
View File
@@ -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'),
),
],
),
),
);
}
}
+1
View File
@@ -0,0 +1 @@
// Empty directory placeholder