refactor: Clean Architecture 적용 및 코드베이스 전면 리팩토링
## 주요 변경사항 ### 아키텍처 개선 - Clean Architecture 패턴 적용 (Domain, Data, Presentation 레이어 분리) - Use Case 패턴 도입으로 비즈니스 로직 캡슐화 - Repository 패턴으로 데이터 접근 추상화 - 의존성 주입 구조 개선 ### 상태 관리 최적화 - 모든 Controller에서 불필요한 상태 관리 로직 제거 - 페이지네이션 로직 통일 및 간소화 - 에러 처리 로직 개선 (에러 메시지 한글화) - 로딩 상태 관리 최적화 ### Mock 서비스 제거 - MockDataService 완전 제거 - 모든 화면을 실제 API 전용으로 전환 - 불필요한 Mock 관련 코드 정리 ### UI/UX 개선 - Overview 화면 대시보드 기능 강화 - 라이선스 만료 알림 위젯 추가 - 사이드바 네비게이션 개선 - 일관된 UI 컴포넌트 사용 ### 코드 품질 - 중복 코드 제거 및 함수 추출 - 파일별 책임 분리 명확화 - 테스트 코드 업데이트 ## 영향 범위 - 모든 화면의 Controller 리팩토링 - API 통신 레이어 구조 개선 - 에러 처리 및 로깅 시스템 개선 ## 향후 계획 - 단위 테스트 커버리지 확대 - 통합 테스트 시나리오 추가 - 성능 모니터링 도구 통합
This commit is contained in:
@@ -47,28 +47,8 @@ class Environment {
|
||||
}
|
||||
}
|
||||
|
||||
/// API 사용 여부 (false면 Mock 데이터 사용)
|
||||
static bool get useApi {
|
||||
try {
|
||||
final useApiStr = dotenv.env['USE_API'];
|
||||
if (enableLogging && kDebugMode) {
|
||||
debugPrint('[Environment] USE_API 원시값: $useApiStr');
|
||||
}
|
||||
if (useApiStr == null || useApiStr.isEmpty) {
|
||||
if (enableLogging && kDebugMode) {
|
||||
debugPrint('[Environment] USE_API가 설정되지 않음, 기본값 true 사용');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
final result = useApiStr.toLowerCase() == 'true';
|
||||
if (enableLogging && kDebugMode) {
|
||||
debugPrint('[Environment] USE_API 최종값: $result');
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
return true; // 기본값
|
||||
}
|
||||
}
|
||||
/// API 사용 여부 (Mock 서비스 제거로 항상 true)
|
||||
static bool get useApi => true;
|
||||
|
||||
/// 환경 초기화
|
||||
static Future<void> initialize([String? environment]) async {
|
||||
@@ -97,8 +77,6 @@ class Environment {
|
||||
debugPrint('[Environment] API Base URL: ${dotenv.env['API_BASE_URL'] ?? '설정되지 않음'}');
|
||||
debugPrint('[Environment] API Timeout: ${dotenv.env['API_TIMEOUT'] ?? '설정되지 않음'}');
|
||||
debugPrint('[Environment] 로깅 활성화: ${dotenv.env['ENABLE_LOGGING'] ?? '설정되지 않음'}');
|
||||
debugPrint('[Environment] API 사용 (원시값): ${dotenv.env['USE_API'] ?? '설정되지 않음'}');
|
||||
debugPrint('[Environment] API 사용 (getter): $useApi');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
|
||||
@@ -5,6 +5,30 @@ class AppConstants {
|
||||
static const int maxPageSize = 100;
|
||||
static const Duration cacheTimeout = Duration(minutes: 5);
|
||||
|
||||
// API 타임아웃
|
||||
static const Duration apiConnectTimeout = Duration(seconds: 30);
|
||||
static const Duration apiReceiveTimeout = Duration(seconds: 30);
|
||||
static const Duration healthCheckTimeout = Duration(seconds: 10);
|
||||
static const Duration loginTimeout = Duration(seconds: 10);
|
||||
|
||||
// 디바운스 시간
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
static const Duration licenseSearchDebounce = Duration(milliseconds: 300);
|
||||
|
||||
// 애니메이션 시간
|
||||
static const Duration autocompleteAnimation = Duration(milliseconds: 200);
|
||||
static const Duration formAnimation = Duration(milliseconds: 300);
|
||||
static const Duration loginAnimation = Duration(milliseconds: 1000);
|
||||
static const Duration loginSubAnimation = Duration(milliseconds: 800);
|
||||
|
||||
// 라이선스 만료 기간
|
||||
static const int licenseExpiryWarningDays = 30;
|
||||
static const int licenseExpiryCautionDays = 60;
|
||||
static const int licenseExpiryInfoDays = 90;
|
||||
|
||||
// 헬스체크 주기
|
||||
static const Duration healthCheckInterval = Duration(seconds: 30);
|
||||
|
||||
// 토큰 키
|
||||
static const String accessTokenKey = 'access_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
|
||||
209
lib/core/controllers/base_list_controller.dart
Normal file
209
lib/core/controllers/base_list_controller.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/error_handler.dart';
|
||||
import '../../data/models/common/pagination_params.dart';
|
||||
|
||||
/// 모든 리스트 컨트롤러의 기본 클래스
|
||||
///
|
||||
/// 페이지네이션, 검색, 필터, 에러 처리 등 공통 기능을 제공합니다.
|
||||
/// 개별 컨트롤러는 이 클래스를 상속받아 특화된 로직만 구현하면 됩니다.
|
||||
abstract class BaseListController<T> extends ChangeNotifier {
|
||||
/// 전체 아이템 목록
|
||||
List<T> _items = [];
|
||||
|
||||
/// 필터링된 아이템 목록
|
||||
List<T> _filteredItems = [];
|
||||
|
||||
/// 로딩 상태
|
||||
bool _isLoading = false;
|
||||
|
||||
/// 에러 메시지
|
||||
String? _error;
|
||||
|
||||
/// 검색 쿼리
|
||||
String _searchQuery = '';
|
||||
|
||||
/// 현재 페이지 번호
|
||||
int _currentPage = 1;
|
||||
|
||||
/// 페이지당 아이템 수
|
||||
int _pageSize = 20;
|
||||
|
||||
/// 더 많은 데이터가 있는지 여부
|
||||
bool _hasMore = true;
|
||||
|
||||
/// 전체 아이템 수 (서버에서 제공하는 실제 전체 개수)
|
||||
int _total = 0;
|
||||
|
||||
/// 총 페이지 수
|
||||
int _totalPages = 0;
|
||||
|
||||
BaseListController();
|
||||
|
||||
// Getters
|
||||
List<T> get items => _filteredItems;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String get searchQuery => _searchQuery;
|
||||
int get currentPage => _currentPage;
|
||||
int get pageSize => _pageSize;
|
||||
bool get hasMore => _hasMore;
|
||||
int get total => _total;
|
||||
int get totalPages => _totalPages;
|
||||
|
||||
// Setters
|
||||
set pageSize(int value) {
|
||||
_pageSize = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Protected setters for subclasses
|
||||
@protected
|
||||
set isLoadingState(bool value) {
|
||||
_isLoading = value;
|
||||
}
|
||||
|
||||
@protected
|
||||
set errorState(String? value) {
|
||||
_error = value;
|
||||
}
|
||||
|
||||
/// 데이터 로드 (하위 클래스에서 구현)
|
||||
/// PagedResult를 반환하여 페이지네이션 메타데이터를 포함
|
||||
Future<PagedResult<T>> fetchData({
|
||||
required PaginationParams params,
|
||||
Map<String, dynamic>? additionalFilters,
|
||||
});
|
||||
|
||||
/// 아이템 필터링 (하위 클래스에서 선택적으로 오버라이드)
|
||||
bool filterItem(T item, String query) {
|
||||
// 기본 구현: toString()을 사용한 간단한 필터링
|
||||
return item.toString().toLowerCase().contains(query.toLowerCase());
|
||||
}
|
||||
|
||||
/// 데이터 로드 및 관리
|
||||
Future<void> loadData({
|
||||
bool isRefresh = false,
|
||||
Map<String, dynamic>? additionalFilters,
|
||||
}) async {
|
||||
if (_isLoading) return;
|
||||
|
||||
if (isRefresh) {
|
||||
_currentPage = 1;
|
||||
_items.clear();
|
||||
_filteredItems.clear();
|
||||
_hasMore = true;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// PaginationParams 생성
|
||||
final params = PaginationParams(
|
||||
page: _currentPage,
|
||||
perPage: _pageSize,
|
||||
search: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||
);
|
||||
|
||||
// 데이터 가져오기
|
||||
final result = await fetchData(
|
||||
params: params,
|
||||
additionalFilters: additionalFilters,
|
||||
);
|
||||
|
||||
if (isRefresh) {
|
||||
_items = result.items;
|
||||
} else {
|
||||
_items.addAll(result.items);
|
||||
}
|
||||
|
||||
// 메타데이터 업데이트
|
||||
_total = result.meta.total;
|
||||
_totalPages = result.meta.totalPages;
|
||||
_hasMore = result.meta.hasNext;
|
||||
|
||||
_applyFiltering();
|
||||
|
||||
if (!isRefresh && result.items.isNotEmpty) {
|
||||
_currentPage++;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is AppFailure) {
|
||||
_error = ErrorHandler.getUserFriendlyMessage(e);
|
||||
} else {
|
||||
_error = '데이터를 불러오는 중 오류가 발생했습니다.';
|
||||
}
|
||||
print('[BaseListController] Error loading data: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 다음 페이지 로드
|
||||
Future<void> loadNextPage() async {
|
||||
if (!_hasMore || _isLoading) return;
|
||||
await loadData(isRefresh: false);
|
||||
}
|
||||
|
||||
/// 검색
|
||||
void search(String query) {
|
||||
_searchQuery = query;
|
||||
_currentPage = 1;
|
||||
_applyFiltering();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 필터링 적용
|
||||
void _applyFiltering() {
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredItems = List.from(_items);
|
||||
} else {
|
||||
_filteredItems = _items.where((item) => filterItem(item, _searchQuery)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 새로고침
|
||||
Future<void> refresh() async {
|
||||
await loadData(isRefresh: true);
|
||||
}
|
||||
|
||||
/// 에러 초기화
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 아이템 추가 (로컬)
|
||||
void addItemLocally(T item) {
|
||||
_items.insert(0, item);
|
||||
_applyFiltering();
|
||||
_total++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 아이템 업데이트 (로컬)
|
||||
void updateItemLocally(T item, bool Function(T) matcher) {
|
||||
final index = _items.indexWhere(matcher);
|
||||
if (index != -1) {
|
||||
_items[index] = item;
|
||||
_applyFiltering();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 아이템 삭제 (로컬)
|
||||
void removeItemLocally(bool Function(T) matcher) {
|
||||
_items.removeWhere(matcher);
|
||||
_applyFiltering();
|
||||
_total--;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,14 @@ abstract class Failure {
|
||||
class ServerFailure extends Failure {
|
||||
final int? statusCode;
|
||||
final Map<String, dynamic>? errors;
|
||||
final dynamic originalError;
|
||||
|
||||
const ServerFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.statusCode,
|
||||
this.errors,
|
||||
this.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,36 +47,49 @@ class CacheFailure extends Failure {
|
||||
|
||||
/// 네트워크 실패
|
||||
class NetworkFailure extends Failure {
|
||||
final dynamic originalError;
|
||||
|
||||
const NetworkFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// 인증 실패
|
||||
class AuthenticationFailure extends Failure {
|
||||
final dynamic originalError;
|
||||
|
||||
const AuthenticationFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// 권한 실패
|
||||
class AuthorizationFailure extends Failure {
|
||||
final dynamic originalError;
|
||||
|
||||
const AuthorizationFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// 유효성 검사 실패
|
||||
class ValidationFailure extends Failure {
|
||||
final Map<String, List<String>>? fieldErrors;
|
||||
final Map<String, dynamic>? errors; // 기존 코드와 호환성을 위해 추가
|
||||
final dynamic originalError; // 원본 에러 정보
|
||||
|
||||
const ValidationFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.fieldErrors,
|
||||
this.errors,
|
||||
this.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +127,23 @@ class BusinessFailure extends Failure {
|
||||
});
|
||||
}
|
||||
|
||||
/// 알 수 없는 실패
|
||||
class UnknownFailure extends Failure {
|
||||
final dynamic originalError;
|
||||
|
||||
const UnknownFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// AuthFailure는 AuthenticationFailure의 별칭
|
||||
typedef AuthFailure = AuthenticationFailure;
|
||||
|
||||
/// PermissionFailure는 AuthorizationFailure의 별칭
|
||||
typedef PermissionFailure = AuthorizationFailure;
|
||||
|
||||
/// 타입 정의
|
||||
typedef FutureEither<T> = Future<Either<Failure, T>>;
|
||||
typedef FutureVoid = FutureEither<void>;
|
||||
289
lib/core/utils/error_handler.dart
Normal file
289
lib/core/utils/error_handler.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 에러 처리 표준화를 위한 유틸리티 클래스
|
||||
class ErrorHandler {
|
||||
/// API 호출을 감싸서 일관된 에러 처리를 제공하는 wrapper 함수
|
||||
///
|
||||
/// 사용 예시:
|
||||
/// ```dart
|
||||
/// final result = await ErrorHandler.handleApiCall(
|
||||
/// () => apiService.getData(),
|
||||
/// onError: (failure) => print('Error: ${failure.message}'),
|
||||
/// );
|
||||
/// ```
|
||||
static Future<T?> handleApiCall<T>(
|
||||
Future<T> Function() apiCall, {
|
||||
Function(AppFailure)? onError,
|
||||
bool showErrorDialog = true,
|
||||
}) async {
|
||||
try {
|
||||
return await apiCall();
|
||||
} on DioException catch (e) {
|
||||
final failure = _handleDioError(e);
|
||||
if (onError != null) {
|
||||
onError(failure);
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('API Error: ${failure.message}');
|
||||
print('Stack trace: ${e.stackTrace}');
|
||||
}
|
||||
return null;
|
||||
} on FormatException catch (e) {
|
||||
final failure = DataParseFailure(
|
||||
message: '데이터 형식 오류: ${e.message}',
|
||||
);
|
||||
if (onError != null) {
|
||||
onError(failure);
|
||||
}
|
||||
return null;
|
||||
} catch (e, stackTrace) {
|
||||
final failure = UnexpectedFailure(
|
||||
message: '예상치 못한 오류가 발생했습니다: ${e.toString()}',
|
||||
);
|
||||
if (onError != null) {
|
||||
onError(failure);
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Unexpected Error: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// DioException을 AppFailure로 변환
|
||||
static AppFailure _handleDioError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return NetworkFailure(
|
||||
message: '네트워크 연결 시간이 초과되었습니다.',
|
||||
statusCode: null,
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
final responseData = error.response?.data;
|
||||
String message = '서버 오류가 발생했습니다.';
|
||||
|
||||
// 서버에서 전달한 에러 메시지가 있으면 사용
|
||||
if (responseData != null) {
|
||||
if (responseData is Map && responseData.containsKey('message')) {
|
||||
message = responseData['message'];
|
||||
} else if (responseData is String) {
|
||||
message = responseData;
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 코드별 메시지 처리
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return ValidationFailure(
|
||||
message: message.isNotEmpty ? message : '잘못된 요청입니다.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
case 401:
|
||||
return AuthenticationFailure(
|
||||
message: '인증이 필요합니다. 다시 로그인해주세요.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
case 403:
|
||||
return AuthorizationFailure(
|
||||
message: '이 작업을 수행할 권한이 없습니다.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
case 404:
|
||||
return NotFoundFailure(
|
||||
message: '요청한 리소스를 찾을 수 없습니다.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
case 409:
|
||||
return ConflictFailure(
|
||||
message: message.isNotEmpty ? message : '중복된 데이터가 있습니다.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
case 422:
|
||||
return ValidationFailure(
|
||||
message: message.isNotEmpty ? message : '입력 데이터가 올바르지 않습니다.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return ServerFailure(
|
||||
message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
statusCode: statusCode,
|
||||
);
|
||||
default:
|
||||
return ServerFailure(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return CancelledFailure(
|
||||
message: '요청이 취소되었습니다.',
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return NetworkFailure(
|
||||
message: '네트워크 연결을 확인해주세요.',
|
||||
statusCode: null,
|
||||
);
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return SecurityFailure(
|
||||
message: '보안 인증서 오류가 발생했습니다.',
|
||||
);
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
return UnexpectedFailure(
|
||||
message: error.message ?? '알 수 없는 오류가 발생했습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 에러 메시지를 사용자 친화적인 메시지로 변환
|
||||
static String getUserFriendlyMessage(AppFailure failure) {
|
||||
if (failure is NetworkFailure) {
|
||||
return '네트워크 연결을 확인해주세요.';
|
||||
} else if (failure is AuthenticationFailure) {
|
||||
return '로그인이 필요합니다.';
|
||||
} else if (failure is AuthorizationFailure) {
|
||||
return '권한이 없습니다.';
|
||||
} else if (failure is ValidationFailure) {
|
||||
return failure.message;
|
||||
} else if (failure is ServerFailure) {
|
||||
return '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
} else {
|
||||
return failure.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 기본 Failure 클래스
|
||||
abstract class AppFailure {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
AppFailure({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// 네트워크 관련 실패
|
||||
class NetworkFailure extends AppFailure {
|
||||
NetworkFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버 관련 실패
|
||||
class ServerFailure extends AppFailure {
|
||||
ServerFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 인증 실패 (401)
|
||||
class AuthenticationFailure extends AppFailure {
|
||||
AuthenticationFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 권한 실패 (403)
|
||||
class AuthorizationFailure extends AppFailure {
|
||||
AuthorizationFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 유효성 검증 실패 (400, 422)
|
||||
class ValidationFailure extends AppFailure {
|
||||
ValidationFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 리소스를 찾을 수 없음 (404)
|
||||
class NotFoundFailure extends AppFailure {
|
||||
NotFoundFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 충돌 발생 (409)
|
||||
class ConflictFailure extends AppFailure {
|
||||
ConflictFailure({
|
||||
required super.message,
|
||||
super.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 데이터 파싱 실패
|
||||
class DataParseFailure extends AppFailure {
|
||||
DataParseFailure({
|
||||
required super.message,
|
||||
});
|
||||
}
|
||||
|
||||
/// 요청 취소됨
|
||||
class CancelledFailure extends AppFailure {
|
||||
CancelledFailure({
|
||||
required super.message,
|
||||
});
|
||||
}
|
||||
|
||||
/// 보안 관련 실패
|
||||
class SecurityFailure extends AppFailure {
|
||||
SecurityFailure({
|
||||
required super.message,
|
||||
});
|
||||
}
|
||||
|
||||
/// 예상치 못한 실패
|
||||
class UnexpectedFailure extends AppFailure {
|
||||
UnexpectedFailure({
|
||||
required super.message,
|
||||
});
|
||||
}
|
||||
|
||||
/// 에러 처리 결과를 담는 Result 타입
|
||||
class Result<T> {
|
||||
final T? data;
|
||||
final AppFailure? failure;
|
||||
|
||||
Result._({this.data, this.failure});
|
||||
|
||||
factory Result.success(T data) => Result._(data: data);
|
||||
factory Result.failure(AppFailure failure) => Result._(failure: failure);
|
||||
|
||||
bool get isSuccess => data != null;
|
||||
bool get isFailure => failure != null;
|
||||
|
||||
R when<R>({
|
||||
required R Function(T data) success,
|
||||
required R Function(AppFailure failure) failure,
|
||||
}) {
|
||||
if (this.data != null) {
|
||||
return success(this.data as T);
|
||||
} else {
|
||||
return failure(this.failure!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ class LoginDiagnostics {
|
||||
/// 환경 설정 확인
|
||||
static Map<String, dynamic> _checkEnvironment() {
|
||||
return {
|
||||
'useApi': env.Environment.useApi,
|
||||
'apiBaseUrl': env.Environment.apiBaseUrl,
|
||||
'isDebugMode': kDebugMode,
|
||||
'platform': defaultTargetPlatform.toString(),
|
||||
@@ -70,20 +69,18 @@ class LoginDiagnostics {
|
||||
}
|
||||
|
||||
// API 서버 연결 테스트
|
||||
if (env.Environment.useApi) {
|
||||
try {
|
||||
final response = await dio.get(
|
||||
'${env.Environment.apiBaseUrl}/health',
|
||||
options: Options(
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
);
|
||||
results['apiServerReachable'] = true;
|
||||
results['apiServerStatus'] = response.statusCode;
|
||||
} catch (e) {
|
||||
results['apiServerReachable'] = false;
|
||||
results['apiServerError'] = e.toString();
|
||||
}
|
||||
try {
|
||||
final response = await dio.get(
|
||||
'${env.Environment.apiBaseUrl}/health',
|
||||
options: Options(
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
);
|
||||
results['apiServerReachable'] = true;
|
||||
results['apiServerStatus'] = response.statusCode;
|
||||
} catch (e) {
|
||||
results['apiServerReachable'] = false;
|
||||
results['apiServerError'] = e.toString();
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -91,9 +88,6 @@ class LoginDiagnostics {
|
||||
|
||||
/// API 엔드포인트 확인
|
||||
static Future<Map<String, dynamic>> _checkApiEndpoint() async {
|
||||
if (!env.Environment.useApi) {
|
||||
return {'mode': 'mock', 'skip': true};
|
||||
}
|
||||
|
||||
final dio = Dio();
|
||||
final results = <String, dynamic>{};
|
||||
|
||||
167
lib/core/widgets/auth_guard.dart
Normal file
167
lib/core/widgets/auth_guard.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../data/models/auth/auth_user.dart';
|
||||
|
||||
/// 역할 기반 접근 제어를 위한 AuthGuard 위젯
|
||||
///
|
||||
/// 사용자의 역할을 확인하여 허용된 역할만 child 위젯에 접근할 수 있도록 제어합니다.
|
||||
/// 권한이 없는 경우 UnauthorizedScreen을 표시합니다.
|
||||
class AuthGuard extends StatelessWidget {
|
||||
/// 접근을 허용할 역할 목록
|
||||
final List<String> allowedRoles;
|
||||
|
||||
/// 권한이 있을 때 표시할 위젯
|
||||
final Widget child;
|
||||
|
||||
/// 권한이 없을 때 표시할 커스텀 위젯 (선택사항)
|
||||
final Widget? unauthorizedWidget;
|
||||
|
||||
/// 권한 체크를 건너뛸지 여부 (개발 모드용)
|
||||
final bool skipCheck;
|
||||
|
||||
const AuthGuard({
|
||||
super.key,
|
||||
required this.allowedRoles,
|
||||
required this.child,
|
||||
this.unauthorizedWidget,
|
||||
this.skipCheck = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 개발 모드에서 권한 체크 건너뛰기
|
||||
if (skipCheck) {
|
||||
return child;
|
||||
}
|
||||
|
||||
// AuthService에서 현재 사용자 정보 가져오기
|
||||
final authService = context.read<AuthService>();
|
||||
|
||||
return FutureBuilder<AuthUser?>(
|
||||
future: authService.getCurrentUser(),
|
||||
builder: (context, snapshot) {
|
||||
// 로딩 중
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// 현재 사용자 정보
|
||||
final currentUser = snapshot.data;
|
||||
|
||||
// 로그인하지 않은 경우
|
||||
if (currentUser == null) {
|
||||
return unauthorizedWidget ?? const UnauthorizedScreen(
|
||||
message: '로그인이 필요합니다.',
|
||||
);
|
||||
}
|
||||
|
||||
// 역할 확인 - 대소문자 구분 없이 비교
|
||||
final userRole = currentUser.role.toLowerCase();
|
||||
final hasPermission = allowedRoles.any(
|
||||
(role) => role.toLowerCase() == userRole,
|
||||
);
|
||||
|
||||
// 권한이 있는 경우
|
||||
if (hasPermission) {
|
||||
return child;
|
||||
}
|
||||
|
||||
// 권한이 없는 경우
|
||||
return unauthorizedWidget ?? UnauthorizedScreen(
|
||||
message: '이 페이지에 접근할 권한이 없습니다.',
|
||||
userRole: currentUser.role,
|
||||
requiredRoles: allowedRoles,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 권한이 없을 때 표시되는 기본 화면
|
||||
class UnauthorizedScreen extends StatelessWidget {
|
||||
final String message;
|
||||
final String? userRole;
|
||||
final List<String>? requiredRoles;
|
||||
|
||||
const UnauthorizedScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.userRole,
|
||||
this.requiredRoles,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lock_outline,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'접근 권한 없음',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (userRole != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'현재 권한: $userRole',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (requiredRoles != null && requiredRoles!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'필요한 권한: ${requiredRoles!.join(", ")}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacementNamed('/dashboard');
|
||||
},
|
||||
child: const Text('대시보드로 이동'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 역할 상수 정의
|
||||
class UserRole {
|
||||
static const String admin = 'Admin';
|
||||
static const String manager = 'Manager';
|
||||
static const String member = 'Member';
|
||||
|
||||
/// 관리자 및 매니저 권한
|
||||
static const List<String> adminAndManager = [admin, manager];
|
||||
|
||||
/// 모든 권한
|
||||
static const List<String> all = [admin, manager, member];
|
||||
|
||||
/// 관리자 전용
|
||||
static const List<String> adminOnly = [admin];
|
||||
}
|
||||
Reference in New Issue
Block a user