전역 구조 리팩터링 및 테스트 확장
This commit is contained in:
@@ -18,12 +18,17 @@ class Environment {
|
||||
/// 프로덕션 여부
|
||||
static late final bool isProduction;
|
||||
|
||||
static final Map<String, Set<String>> _permissions = {};
|
||||
|
||||
/// 환경 초기화
|
||||
///
|
||||
/// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능
|
||||
/// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다.
|
||||
static Future<void> initialize() async {
|
||||
const envFromDefine = String.fromEnvironment('ENV', defaultValue: 'development');
|
||||
const envFromDefine = String.fromEnvironment(
|
||||
'ENV',
|
||||
defaultValue: 'development',
|
||||
);
|
||||
envName = envFromDefine.toLowerCase();
|
||||
isProduction = envName == 'production';
|
||||
|
||||
@@ -46,6 +51,7 @@ class Environment {
|
||||
}
|
||||
|
||||
baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080';
|
||||
_loadPermissions();
|
||||
}
|
||||
|
||||
/// 기능 플래그 조회 (기본 false)
|
||||
@@ -67,4 +73,32 @@ class Environment {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
static void _loadPermissions() {
|
||||
_permissions.clear();
|
||||
for (final entry in dotenv.env.entries) {
|
||||
const prefix = 'PERMISSION__';
|
||||
if (!entry.key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
final resource = entry.key.substring(prefix.length).toLowerCase();
|
||||
final values = entry.value
|
||||
.split(',')
|
||||
.map((token) => token.trim().toLowerCase())
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toSet();
|
||||
_permissions[resource] = values;
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasPermission(String resource, String action) {
|
||||
final actions = _permissions[resource.toLowerCase()];
|
||||
if (actions == null || actions.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (actions.contains('all')) {
|
||||
return true;
|
||||
}
|
||||
return actions.contains(action.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
class AppPageDescriptor {
|
||||
const AppPageDescriptor({
|
||||
@@ -32,7 +32,7 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: dashboardRoutePath,
|
||||
label: '대시보드',
|
||||
icon: LucideIcons.layoutDashboard,
|
||||
icon: lucide.LucideIcons.layoutDashboard,
|
||||
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
|
||||
),
|
||||
],
|
||||
@@ -43,19 +43,19 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/inventory/inbound',
|
||||
label: '입고',
|
||||
icon: LucideIcons.packagePlus,
|
||||
icon: lucide.LucideIcons.packagePlus,
|
||||
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/inventory/outbound',
|
||||
label: '출고',
|
||||
icon: LucideIcons.packageMinus,
|
||||
icon: lucide.LucideIcons.packageMinus,
|
||||
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/inventory/rental',
|
||||
label: '대여',
|
||||
icon: LucideIcons.handshake,
|
||||
icon: lucide.LucideIcons.handshake,
|
||||
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
|
||||
),
|
||||
],
|
||||
@@ -66,49 +66,49 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/masters/vendors',
|
||||
label: '제조사 관리',
|
||||
icon: LucideIcons.factory,
|
||||
icon: lucide.LucideIcons.factory,
|
||||
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/products',
|
||||
label: '장비 모델 관리',
|
||||
icon: LucideIcons.box,
|
||||
icon: lucide.LucideIcons.box,
|
||||
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/warehouses',
|
||||
label: '입고지 관리',
|
||||
icon: LucideIcons.warehouse,
|
||||
icon: lucide.LucideIcons.warehouse,
|
||||
summary: '창고 주소와 사용여부를 설정합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/customers',
|
||||
label: '회사 관리',
|
||||
icon: LucideIcons.building,
|
||||
icon: lucide.LucideIcons.building,
|
||||
summary: '고객사 연락처와 주소 정보를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/users',
|
||||
label: '사용자 관리',
|
||||
icon: LucideIcons.users,
|
||||
icon: lucide.LucideIcons.users,
|
||||
summary: '사번, 그룹, 사용여부를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/groups',
|
||||
label: '그룹 관리',
|
||||
icon: LucideIcons.layers,
|
||||
icon: lucide.LucideIcons.layers,
|
||||
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/menus',
|
||||
label: '메뉴 관리',
|
||||
icon: LucideIcons.listTree,
|
||||
icon: lucide.LucideIcons.listTree,
|
||||
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/group-permissions',
|
||||
label: '그룹 메뉴 권한',
|
||||
icon: LucideIcons.shieldCheck,
|
||||
icon: lucide.LucideIcons.shieldCheck,
|
||||
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
|
||||
),
|
||||
],
|
||||
@@ -119,25 +119,25 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/requests',
|
||||
label: '결재 관리',
|
||||
icon: LucideIcons.fileCheck,
|
||||
icon: lucide.LucideIcons.fileCheck,
|
||||
summary: '결재 번호, 상태, 상신자를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/steps',
|
||||
label: '결재 단계',
|
||||
icon: LucideIcons.workflow,
|
||||
icon: lucide.LucideIcons.workflow,
|
||||
summary: '단계 순서와 승인자 할당을 설정합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/history',
|
||||
label: '결재 이력',
|
||||
icon: LucideIcons.history,
|
||||
icon: lucide.LucideIcons.history,
|
||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/templates',
|
||||
label: '결재 템플릿',
|
||||
icon: LucideIcons.fileSpreadsheet,
|
||||
icon: lucide.LucideIcons.fileSpreadsheet,
|
||||
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
|
||||
),
|
||||
],
|
||||
@@ -148,7 +148,7 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/utilities/postal-search',
|
||||
label: '우편번호 검색',
|
||||
icon: LucideIcons.search,
|
||||
icon: lucide.LucideIcons.search,
|
||||
summary: '모달 기반 우편번호 검색 도구입니다.',
|
||||
),
|
||||
],
|
||||
@@ -159,7 +159,7 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/reports',
|
||||
label: '보고서',
|
||||
icon: LucideIcons.fileDown,
|
||||
icon: lucide.LucideIcons.fileDown,
|
||||
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_error.dart';
|
||||
|
||||
/// 공통 API 클라이언트 (Dio 래퍼)
|
||||
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
|
||||
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
|
||||
class ApiClient {
|
||||
ApiClient({required Dio dio, ApiErrorMapper? errorMapper})
|
||||
: _dio = dio,
|
||||
_errorMapper = errorMapper ?? const ApiErrorMapper();
|
||||
|
||||
final Dio _dio;
|
||||
final ApiErrorMapper _errorMapper;
|
||||
|
||||
/// 내부에서 사용하는 Dio 인스턴스
|
||||
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
|
||||
Dio get dio => _dio;
|
||||
|
||||
ApiClient({required Dio dio}) : _dio = dio;
|
||||
|
||||
/// GET 요청 헬퍼
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
@@ -21,7 +26,14 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.get<T>(
|
||||
path,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// POST 요청 헬퍼
|
||||
@@ -32,7 +44,15 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// PATCH 요청 헬퍼
|
||||
@@ -43,7 +63,15 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.patch<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// DELETE 요청 헬퍼
|
||||
@@ -54,7 +82,22 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.delete<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response<T>> _wrap<T>(Future<Response<T>> Function() request) async {
|
||||
try {
|
||||
return await request();
|
||||
} on DioException catch (error) {
|
||||
throw _errorMapper.map(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
148
lib/core/network/api_error.dart
Normal file
148
lib/core/network/api_error.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
enum ApiErrorCode {
|
||||
badRequest,
|
||||
unauthorized,
|
||||
notFound,
|
||||
conflict,
|
||||
unprocessableEntity,
|
||||
network,
|
||||
timeout,
|
||||
cancel,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
this.cause,
|
||||
});
|
||||
|
||||
final ApiErrorCode code;
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final Map<String, dynamic>? details;
|
||||
final DioException? cause;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException(code: $code, statusCode: $statusCode, message: $message)';
|
||||
}
|
||||
|
||||
class ApiErrorMapper {
|
||||
const ApiErrorMapper();
|
||||
|
||||
ApiException map(DioException error) {
|
||||
final status = error.response?.statusCode;
|
||||
final data = error.response?.data;
|
||||
final message = _resolveMessage(error, data);
|
||||
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.timeout,
|
||||
message: '서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.badCertificate) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.network,
|
||||
message: '네트워크 연결에 실패했습니다. 잠시 후 다시 시도하세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.cancel) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.cancel,
|
||||
message: '요청이 취소되었습니다.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
final details = _extractDetails(data);
|
||||
switch (status) {
|
||||
case 400:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.badRequest,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
case 401:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unauthorized,
|
||||
message: '세션이 만료되었습니다. 다시 로그인해 주세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
case 404:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.notFound,
|
||||
message: '요청한 리소스를 찾을 수 없습니다.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
case 409:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.conflict,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
case 422:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unprocessableEntity,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unknown,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveMessage(DioException error, dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'] ?? data['error'];
|
||||
if (message is String && message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _extractDetails(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final errors = data['errors'];
|
||||
if (errors is Map<String, dynamic>) {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,124 @@
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 인증 인터셉터(스켈레톤)
|
||||
import '../../services/token_storage.dart';
|
||||
|
||||
typedef RefreshTokenCallback = Future<TokenPair?> Function();
|
||||
|
||||
class TokenPair {
|
||||
const TokenPair({required this.accessToken, required this.refreshToken});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
}
|
||||
|
||||
/// 인증 인터셉터
|
||||
/// - 요청 전에 Authorization 헤더 주입
|
||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
|
||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도
|
||||
class AuthInterceptor extends Interceptor {
|
||||
/// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository)
|
||||
AuthInterceptor();
|
||||
AuthInterceptor({
|
||||
required TokenStorage tokenStorage,
|
||||
required Dio dio,
|
||||
this.onRefresh,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_dio = dio;
|
||||
|
||||
final TokenStorage _tokenStorage;
|
||||
final Dio _dio;
|
||||
final RefreshTokenCallback? onRefresh;
|
||||
|
||||
final List<Completer<void>> _refreshQueue = [];
|
||||
bool _isRefreshing = false;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
// TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다.
|
||||
// final token = await _authRepository.getToken();
|
||||
// if (token != null && token.isNotEmpty) {
|
||||
// options.headers['Authorization'] = 'Bearer $token';
|
||||
// }
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final token = await _tokenStorage.readAccessToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
|
||||
// if (err.response?.statusCode == 401) { ... }
|
||||
handler.next(err);
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (!_shouldAttemptRefresh(err)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _refreshToken();
|
||||
final response = await _retry(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
} on _RefreshFailedException {
|
||||
await _tokenStorage.clear();
|
||||
handler.next(err);
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
} catch (_) {
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAttemptRefresh(DioException err) {
|
||||
return onRefresh != null &&
|
||||
err.response?.statusCode == 401 &&
|
||||
err.requestOptions.extra['__retry'] != true;
|
||||
}
|
||||
|
||||
Future<void> _refreshToken() async {
|
||||
if (_isRefreshing) {
|
||||
final completer = Completer<void>();
|
||||
_refreshQueue.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final callback = onRefresh;
|
||||
if (callback == null) {
|
||||
throw const _RefreshFailedException();
|
||||
}
|
||||
|
||||
final pair = await callback();
|
||||
if (pair == null) {
|
||||
throw const _RefreshFailedException();
|
||||
}
|
||||
|
||||
await _tokenStorage.writeAccessToken(pair.accessToken);
|
||||
await _tokenStorage.writeRefreshToken(pair.refreshToken);
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
for (final completer in _refreshQueue) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
_refreshQueue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
|
||||
final token = await _tokenStorage.readAccessToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
requestOptions.headers['Authorization'] = 'Bearer $token';
|
||||
} else {
|
||||
requestOptions.headers.remove('Authorization');
|
||||
}
|
||||
requestOptions.extra['__retry'] = true;
|
||||
return _dio.fetch(requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshFailedException implements Exception {
|
||||
const _RefreshFailedException();
|
||||
}
|
||||
|
||||
87
lib/core/permissions/permission_manager.dart
Normal file
87
lib/core/permissions/permission_manager.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../config/environment.dart';
|
||||
|
||||
enum PermissionAction { view, create, edit, delete, restore, approve }
|
||||
|
||||
class PermissionManager extends ChangeNotifier {
|
||||
PermissionManager({Map<String, Set<PermissionAction>>? overrides}) {
|
||||
if (overrides != null) {
|
||||
_overrides.addAll(overrides);
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, Set<PermissionAction>> _overrides = {};
|
||||
|
||||
bool can(String resource, PermissionAction action) {
|
||||
final override = _overrides[resource];
|
||||
if (override != null) {
|
||||
if (override.contains(PermissionAction.view) &&
|
||||
action == PermissionAction.view) {
|
||||
return true;
|
||||
}
|
||||
return override.contains(action);
|
||||
}
|
||||
return Environment.hasPermission(resource, action.name);
|
||||
}
|
||||
|
||||
void updateOverrides(Map<String, Set<PermissionAction>> overrides) {
|
||||
_overrides
|
||||
..clear()
|
||||
..addAll(overrides);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionScope extends InheritedNotifier<PermissionManager> {
|
||||
const PermissionScope({
|
||||
super.key,
|
||||
required PermissionManager manager,
|
||||
required super.child,
|
||||
}) : super(notifier: manager);
|
||||
|
||||
static PermissionManager of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<PermissionScope>();
|
||||
assert(
|
||||
scope != null,
|
||||
'PermissionScope.of() called with no PermissionScope ancestor.',
|
||||
);
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionGate extends StatelessWidget {
|
||||
const PermissionGate({
|
||||
super.key,
|
||||
required this.resource,
|
||||
required this.action,
|
||||
required this.child,
|
||||
this.fallback,
|
||||
this.hide = true,
|
||||
});
|
||||
|
||||
final String resource;
|
||||
final PermissionAction action;
|
||||
final Widget child;
|
||||
final Widget? fallback;
|
||||
final bool hide;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allowed = PermissionScope.of(context).can(resource, action);
|
||||
if (allowed) {
|
||||
return child;
|
||||
}
|
||||
if (hide) {
|
||||
return fallback ?? const SizedBox.shrink();
|
||||
}
|
||||
return IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Opacity(opacity: 0.4, child: fallback ?? child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension PermissionActionKey on PermissionAction {
|
||||
String get key => name;
|
||||
}
|
||||
@@ -47,32 +47,32 @@ final appRouter = GoRouter(
|
||||
GoRoute(
|
||||
path: '/inventory/inbound',
|
||||
name: 'inventory-inbound',
|
||||
builder: (context, state) => const InboundPage(),
|
||||
builder: (context, state) => InboundPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/outbound',
|
||||
name: 'inventory-outbound',
|
||||
builder: (context, state) => const OutboundPage(),
|
||||
builder: (context, state) => OutboundPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/rental',
|
||||
name: 'inventory-rental',
|
||||
builder: (context, state) => const RentalPage(),
|
||||
builder: (context, state) => RentalPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/vendors',
|
||||
name: 'masters-vendors',
|
||||
builder: (context, state) => const VendorPage(),
|
||||
builder: (context, state) => VendorPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/products',
|
||||
name: 'masters-products',
|
||||
builder: (context, state) => const ProductPage(),
|
||||
builder: (context, state) => ProductPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/warehouses',
|
||||
name: 'masters-warehouses',
|
||||
builder: (context, state) => const WarehousePage(),
|
||||
builder: (context, state) => WarehousePage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/customers',
|
||||
|
||||
19
lib/core/services/token_storage.dart
Normal file
19
lib/core/services/token_storage.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'token_storage_stub.dart'
|
||||
if (dart.library.html) 'token_storage_web.dart'
|
||||
if (dart.library.io) 'token_storage_native.dart';
|
||||
|
||||
/// 액세스/리프레시 토큰을 안전하게 보관하는 스토리지 인터페이스.
|
||||
abstract class TokenStorage {
|
||||
Future<void> writeAccessToken(String? token);
|
||||
|
||||
Future<String?> readAccessToken();
|
||||
|
||||
Future<void> writeRefreshToken(String? token);
|
||||
|
||||
Future<String?> readRefreshToken();
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// 플랫폼에 맞는 스토리지 구현체를 생성한다.
|
||||
TokenStorage createTokenStorage() => buildTokenStorage();
|
||||
53
lib/core/services/token_storage_native.dart
Normal file
53
lib/core/services/token_storage_native.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'token_storage.dart';
|
||||
|
||||
const _kAccessTokenKey = 'access_token';
|
||||
const _kRefreshTokenKey = 'refresh_token';
|
||||
|
||||
TokenStorage buildTokenStorage() {
|
||||
const storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
mOptions: MacOsOptions(),
|
||||
wOptions: WindowsOptions(),
|
||||
lOptions: LinuxOptions(),
|
||||
);
|
||||
return _SecureTokenStorage(storage);
|
||||
}
|
||||
|
||||
class _SecureTokenStorage implements TokenStorage {
|
||||
const _SecureTokenStorage(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _kAccessTokenKey);
|
||||
await _storage.delete(key: _kRefreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() => _storage.read(key: _kAccessTokenKey);
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() => _storage.read(key: _kRefreshTokenKey);
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
await _storage.delete(key: _kAccessTokenKey);
|
||||
return;
|
||||
}
|
||||
await _storage.write(key: _kAccessTokenKey, value: token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
await _storage.delete(key: _kRefreshTokenKey);
|
||||
return;
|
||||
}
|
||||
await _storage.write(key: _kRefreshTokenKey, value: token);
|
||||
}
|
||||
}
|
||||
24
lib/core/services/token_storage_stub.dart
Normal file
24
lib/core/services/token_storage_stub.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'token_storage.dart';
|
||||
|
||||
TokenStorage buildTokenStorage() => _UnsupportedTokenStorage();
|
||||
|
||||
class _UnsupportedTokenStorage implements TokenStorage {
|
||||
Never _unsupported() {
|
||||
throw UnsupportedError('TokenStorage is not supported on this platform.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async => _unsupported();
|
||||
}
|
||||
45
lib/core/services/token_storage_web.dart
Normal file
45
lib/core/services/token_storage_web.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
// ignore: deprecated_member_use, avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'token_storage.dart';
|
||||
|
||||
const _kAccessTokenKey = 'access_token';
|
||||
const _kRefreshTokenKey = 'refresh_token';
|
||||
|
||||
TokenStorage buildTokenStorage() => _WebTokenStorage(html.window.localStorage);
|
||||
|
||||
class _WebTokenStorage implements TokenStorage {
|
||||
const _WebTokenStorage(this._storage);
|
||||
|
||||
final Map<String, String> _storage;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
_storage.remove(_kAccessTokenKey);
|
||||
_storage.remove(_kRefreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() async => _storage[_kAccessTokenKey];
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() async => _storage[_kRefreshTokenKey];
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
_storage.remove(_kAccessTokenKey);
|
||||
} else {
|
||||
_storage[_kAccessTokenKey] = token;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
_storage.remove(_kRefreshTokenKey);
|
||||
} else {
|
||||
_storage[_kRefreshTokenKey] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
lib/core/theme/superport_shad_theme.dart
Normal file
208
lib/core/theme/superport_shad_theme.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// Superport UI에서 공통으로 사용하는 Shad 테마 정의.
|
||||
class SuperportShadTheme {
|
||||
const SuperportShadTheme._();
|
||||
|
||||
static const Color primaryColor = Color(0xFF1B4F87);
|
||||
static const Color successColor = Color(0xFF2E8B57);
|
||||
static const Color warningColor = Color(0xFFFFC107);
|
||||
static const Color dangerColor = Color(0xFFDC3545);
|
||||
static const Color infoColor = Color(0xFF17A2B8);
|
||||
|
||||
/// 라이트 모드용 Shad 테마를 반환한다.
|
||||
static ShadThemeData light() {
|
||||
return ShadThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ShadColorScheme(
|
||||
background: Color(0xFFFFFFFF),
|
||||
foreground: Color(0xFF09090B),
|
||||
card: Color(0xFFFFFFFF),
|
||||
cardForeground: Color(0xFF09090B),
|
||||
popover: Color(0xFFFFFFFF),
|
||||
popoverForeground: Color(0xFF09090B),
|
||||
primary: primaryColor,
|
||||
primaryForeground: Color(0xFFFAFAFA),
|
||||
secondary: Color(0xFFF4F4F5),
|
||||
secondaryForeground: Color(0xFF18181B),
|
||||
muted: Color(0xFFF4F4F5),
|
||||
mutedForeground: Color(0xFF71717A),
|
||||
accent: Color(0xFFF4F4F5),
|
||||
accentForeground: Color(0xFF18181B),
|
||||
destructive: Color(0xFFEF4444),
|
||||
destructiveForeground: Color(0xFFFAFAFA),
|
||||
border: Color(0xFFE4E4E7),
|
||||
input: Color(0xFFE4E4E7),
|
||||
ring: Color(0xFF18181B),
|
||||
selection: primaryColor,
|
||||
),
|
||||
textTheme: ShadTextTheme(
|
||||
h1: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h2: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h3: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h4: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
p: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
blockquote: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
table: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
list: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
lead: TextStyle(fontSize: 20, fontWeight: FontWeight.w400),
|
||||
large: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
small: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
muted: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
radius: const BorderRadius.all(Radius.circular(8)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 다크 모드용 Shad 테마를 반환한다.
|
||||
static ShadThemeData dark() {
|
||||
return ShadThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ShadColorScheme(
|
||||
background: Color(0xFF09090B),
|
||||
foreground: Color(0xFFFAFAFA),
|
||||
card: Color(0xFF09090B),
|
||||
cardForeground: Color(0xFFFAFAFA),
|
||||
popover: Color(0xFF09090B),
|
||||
popoverForeground: Color(0xFFFAFAFA),
|
||||
primary: primaryColor,
|
||||
primaryForeground: Color(0xFFFAFAFA),
|
||||
secondary: Color(0xFF27272A),
|
||||
secondaryForeground: Color(0xFFFAFAFA),
|
||||
muted: Color(0xFF27272A),
|
||||
mutedForeground: Color(0xFFA1A1AA),
|
||||
accent: Color(0xFF27272A),
|
||||
accentForeground: Color(0xFFFAFAFA),
|
||||
destructive: Color(0xFF7F1D1D),
|
||||
destructiveForeground: Color(0xFFFAFAFA),
|
||||
border: Color(0xFF27272A),
|
||||
input: Color(0xFF27272A),
|
||||
ring: Color(0xFFD4D4D8),
|
||||
selection: primaryColor,
|
||||
),
|
||||
textTheme: ShadTextTheme(
|
||||
h1: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h2: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h3: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h4: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
p: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
blockquote: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
table: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
list: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
lead: TextStyle(fontSize: 20, fontWeight: FontWeight.w400),
|
||||
large: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
small: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
muted: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
radius: const BorderRadius.all(Radius.circular(8)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 상태 텍스트 배경을 위한 데코레이션을 반환한다.
|
||||
static BoxDecoration statusDecoration(String status) {
|
||||
Color backgroundColor;
|
||||
Color borderColor;
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
case 'success':
|
||||
backgroundColor = successColor.withValues(alpha: 0.1);
|
||||
borderColor = successColor;
|
||||
break;
|
||||
case 'warning':
|
||||
case 'pending':
|
||||
backgroundColor = warningColor.withValues(alpha: 0.1);
|
||||
borderColor = warningColor;
|
||||
break;
|
||||
case 'danger':
|
||||
case 'error':
|
||||
backgroundColor = dangerColor.withValues(alpha: 0.1);
|
||||
borderColor = dangerColor;
|
||||
break;
|
||||
case 'info':
|
||||
backgroundColor = infoColor.withValues(alpha: 0.1);
|
||||
borderColor = infoColor;
|
||||
break;
|
||||
case 'inactive':
|
||||
case 'disabled':
|
||||
default:
|
||||
backgroundColor = Colors.grey.withValues(alpha: 0.1);
|
||||
borderColor = Colors.grey;
|
||||
}
|
||||
|
||||
return BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
lib/core/theme/theme_controller.dart
Normal file
47
lib/core/theme/theme_controller.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 전역 테마 모드를 관리하는 컨트롤러.
|
||||
class ThemeController extends ChangeNotifier {
|
||||
ThemeController({ThemeMode initialMode = ThemeMode.system})
|
||||
: _mode = initialMode;
|
||||
|
||||
ThemeMode _mode;
|
||||
|
||||
ThemeMode get mode => _mode;
|
||||
|
||||
void update(ThemeMode mode) {
|
||||
if (_mode == mode) return;
|
||||
_mode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cycle() {
|
||||
switch (_mode) {
|
||||
case ThemeMode.system:
|
||||
update(ThemeMode.light);
|
||||
break;
|
||||
case ThemeMode.light:
|
||||
update(ThemeMode.dark);
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
update(ThemeMode.system);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [ThemeController]를 하위 위젯에 제공하는 Inherited 위젯.
|
||||
class ThemeControllerScope extends InheritedNotifier<ThemeController> {
|
||||
const ThemeControllerScope({
|
||||
super.key,
|
||||
required ThemeController controller,
|
||||
required super.child,
|
||||
}) : super(notifier: controller);
|
||||
|
||||
static ThemeController of(BuildContext context) {
|
||||
final scope = context
|
||||
.dependOnInheritedWidgetOfExactType<ThemeControllerScope>();
|
||||
assert(scope != null, 'ThemeControllerScope가 위젯 트리에 없습니다.');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user