전역 구조 리팩터링 및 테스트 확장

This commit is contained in:
JiWoong Sul
2025-09-29 01:51:47 +09:00
parent c00c0c9ab2
commit fef7108479
70 changed files with 7709 additions and 3185 deletions

View File

@@ -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());
}
}

View File

@@ -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 다운로드 기능입니다.',
),
],

View File

@@ -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);
}
}
}

View 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;
}
}

View File

@@ -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();
}

View 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;
}

View File

@@ -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',

View 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();

View 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);
}
}

View 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();
}

View 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;
}
}
}

View 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),
);
}
}

View 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!;
}
}