초기 커밋
This commit is contained in:
27
lib/core/constants/api_constants.dart
Normal file
27
lib/core/constants/api_constants.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
abstract final class ApiConstants {
|
||||
static const String baseUrl = 'http://localhost:8000/api/v1';
|
||||
static const String wsUrl = 'ws://localhost:8000/ws';
|
||||
|
||||
// Auth
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
static const String logout = '/auth/logout';
|
||||
|
||||
// Users
|
||||
static const String users = '/users';
|
||||
static const String userProfile = '/users/me';
|
||||
|
||||
// Dashboard
|
||||
static const String dashboardStats = '/dashboard/stats';
|
||||
static const String dashboardMetrics = '/dashboard/metrics';
|
||||
|
||||
// Admin
|
||||
static const String adminUsers = '/admin/users';
|
||||
static const String adminSettings = '/admin/settings';
|
||||
|
||||
// Timeouts
|
||||
static const Duration connectTimeout = Duration(seconds: 15);
|
||||
static const Duration receiveTimeout = Duration(seconds: 15);
|
||||
static const Duration sendTimeout = Duration(seconds: 15);
|
||||
}
|
||||
22
lib/core/constants/app_constants.dart
Normal file
22
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
abstract final class AppConstants {
|
||||
static const String appName = 'Flutter Frame';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// Storage Keys
|
||||
static const String accessTokenKey = 'access_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
static const String userKey = 'user_data';
|
||||
static const String themeKey = 'theme_mode';
|
||||
static const String localeKey = 'locale';
|
||||
|
||||
// Pagination
|
||||
static const int defaultPageSize = 20;
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
// Debounce
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
// Animation
|
||||
static const Duration animationDuration = Duration(milliseconds: 300);
|
||||
static const Duration shortAnimationDuration = Duration(milliseconds: 150);
|
||||
}
|
||||
15
lib/core/env/env.dart
vendored
Normal file
15
lib/core/env/env.dart
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:envied/envied.dart';
|
||||
|
||||
part 'env.g.dart';
|
||||
|
||||
@Envied(path: '.env', obfuscate: true)
|
||||
abstract class Env {
|
||||
@EnviedField(varName: 'API_BASE_URL', defaultValue: 'http://localhost:8000/api/v1')
|
||||
static String apiBaseUrl = _Env.apiBaseUrl;
|
||||
|
||||
@EnviedField(varName: 'WS_BASE_URL', defaultValue: 'ws://localhost:8000/ws')
|
||||
static String wsBaseUrl = _Env.wsBaseUrl;
|
||||
|
||||
@EnviedField(varName: 'ENV', defaultValue: 'development')
|
||||
static String environment = _Env.environment;
|
||||
}
|
||||
39
lib/core/error/exceptions.dart
Normal file
39
lib/core/error/exceptions.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class ServerException implements Exception {
|
||||
const ServerException({
|
||||
required this.message,
|
||||
required this.statusCode,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int statusCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException(message: $message, statusCode: $statusCode)';
|
||||
}
|
||||
|
||||
class CacheException implements Exception {
|
||||
const CacheException({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'CacheException(message: $message)';
|
||||
}
|
||||
|
||||
class NetworkException implements Exception {
|
||||
const NetworkException({this.message = 'No internet connection'});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException(message: $message)';
|
||||
}
|
||||
|
||||
class UnauthorizedException implements Exception {
|
||||
const UnauthorizedException({this.message = 'Unauthorized'});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedException(message: $message)';
|
||||
}
|
||||
27
lib/core/error/failures.dart
Normal file
27
lib/core/error/failures.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'failures.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class Failure with _$Failure {
|
||||
const factory Failure.server({
|
||||
required String message,
|
||||
@Default(500) int statusCode,
|
||||
}) = ServerFailure;
|
||||
|
||||
const factory Failure.cache({
|
||||
required String message,
|
||||
}) = CacheFailure;
|
||||
|
||||
const factory Failure.network({
|
||||
@Default('네트워크 연결을 확인해주세요') String message,
|
||||
}) = NetworkFailure;
|
||||
|
||||
const factory Failure.unauthorized({
|
||||
@Default('인증이 필요합니다') String message,
|
||||
}) = UnauthorizedFailure;
|
||||
|
||||
const factory Failure.unknown({
|
||||
@Default('알 수 없는 오류가 발생했습니다') String message,
|
||||
}) = UnknownFailure;
|
||||
}
|
||||
22
lib/core/logging/app_logger.dart
Normal file
22
lib/core/logging/app_logger.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:talker/talker.dart';
|
||||
|
||||
part 'app_logger.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Talker appLogger(Ref ref) {
|
||||
return Talker(
|
||||
settings: TalkerSettings(
|
||||
useHistory: true,
|
||||
useConsoleLogs: true,
|
||||
maxHistoryItems: 1000,
|
||||
),
|
||||
logger: TalkerLogger(
|
||||
settings: TalkerLoggerSettings(
|
||||
level: LogLevel.verbose,
|
||||
enableColors: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
26
lib/core/network/auth_interceptor.dart
Normal file
26
lib/core/network/auth_interceptor.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor({required this.ref});
|
||||
|
||||
final Ref ref;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
|
||||
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
44
lib/core/network/dio_client.dart
Normal file
44
lib/core/network/dio_client.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger.dart';
|
||||
|
||||
import '../constants/api_constants.dart';
|
||||
import '../logging/app_logger.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
import 'token_refresh_interceptor.dart';
|
||||
|
||||
part 'dio_client.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dio(Ref ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ApiConstants.baseUrl,
|
||||
connectTimeout: ApiConstants.connectTimeout,
|
||||
receiveTimeout: ApiConstants.receiveTimeout,
|
||||
sendTimeout: ApiConstants.sendTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final talker = ref.read(appLoggerProvider);
|
||||
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(ref: ref),
|
||||
TokenRefreshInterceptor(ref: ref, dio: dio),
|
||||
TalkerDioLogger(
|
||||
talker: talker,
|
||||
settings: const TalkerDioLoggerSettings(
|
||||
printRequestHeaders: true,
|
||||
printResponseHeaders: false,
|
||||
printResponseData: true,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return dio;
|
||||
}
|
||||
109
lib/core/network/socket_manager.dart
Normal file
109
lib/core/network/socket_manager.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:talker/talker.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../constants/api_constants.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../logging/app_logger.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
part 'socket_manager.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SocketManager socketManager(Ref ref) {
|
||||
final talker = ref.read(appLoggerProvider);
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
final manager = SocketManager(talker: talker, secureStorage: secureStorage);
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
}
|
||||
|
||||
class SocketManager {
|
||||
SocketManager({
|
||||
required this.talker,
|
||||
required this.secureStorage,
|
||||
});
|
||||
|
||||
final Talker talker;
|
||||
final SecureStorage secureStorage;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _reconnectTimer;
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
bool _isConnected = false;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
Future<void> connect({String? path}) async {
|
||||
try {
|
||||
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
|
||||
final wsUrl = '${ApiConstants.wsUrl}${path ?? ''}';
|
||||
final uri = Uri.parse(
|
||||
token != null ? '$wsUrl?token=$token' : wsUrl,
|
||||
);
|
||||
|
||||
_channel = WebSocketChannel.connect(uri);
|
||||
await _channel!.ready;
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
talker.info('WebSocket connected: $wsUrl');
|
||||
|
||||
_channel!.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
final decoded = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
_messageController.add(decoded);
|
||||
} catch (e) {
|
||||
talker.error('WebSocket message parse error', e);
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
talker.error('WebSocket error', error);
|
||||
_handleDisconnect();
|
||||
},
|
||||
onDone: () {
|
||||
talker.warning('WebSocket disconnected');
|
||||
_handleDisconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
talker.error('WebSocket connection failed', e);
|
||||
_handleDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDisconnect() {
|
||||
_isConnected = false;
|
||||
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||||
final delay = Duration(seconds: _reconnectAttempts * 2 + 1);
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
_reconnectAttempts++;
|
||||
talker.info('WebSocket reconnect attempt $_reconnectAttempts');
|
||||
connect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void send(Map<String, dynamic> data) {
|
||||
if (_isConnected && _channel != null) {
|
||||
_channel!.sink.add(jsonEncode(data));
|
||||
} else {
|
||||
talker.warning('WebSocket not connected, message not sent');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_reconnectTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_messageController.close();
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
77
lib/core/network/token_refresh_interceptor.dart
Normal file
77
lib/core/network/token_refresh_interceptor.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../constants/api_constants.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
class TokenRefreshInterceptor extends Interceptor {
|
||||
TokenRefreshInterceptor({
|
||||
required this.ref,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
final Ref ref;
|
||||
final Dio dio;
|
||||
bool _isRefreshing = false;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401 && !_isRefreshing) {
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
final refreshToken = await secureStorage.read(
|
||||
key: AppConstants.refreshTokenKey,
|
||||
);
|
||||
|
||||
if (refreshToken == null) {
|
||||
_isRefreshing = false;
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// Request new tokens
|
||||
final refreshDio = Dio(
|
||||
BaseOptions(baseUrl: ApiConstants.baseUrl),
|
||||
);
|
||||
final response = await refreshDio.post(
|
||||
ApiConstants.refreshToken,
|
||||
data: {'refresh_token': refreshToken},
|
||||
);
|
||||
|
||||
final newAccessToken = response.data['access_token'] as String;
|
||||
final newRefreshToken = response.data['refresh_token'] as String;
|
||||
|
||||
// Save new tokens
|
||||
await secureStorage.write(
|
||||
key: AppConstants.accessTokenKey,
|
||||
value: newAccessToken,
|
||||
);
|
||||
await secureStorage.write(
|
||||
key: AppConstants.refreshTokenKey,
|
||||
value: newRefreshToken,
|
||||
);
|
||||
|
||||
// Retry original request
|
||||
final options = err.requestOptions;
|
||||
options.headers['Authorization'] = 'Bearer $newAccessToken';
|
||||
|
||||
final retryResponse = await dio.fetch(options);
|
||||
_isRefreshing = false;
|
||||
return handler.resolve(retryResponse);
|
||||
} on DioException {
|
||||
_isRefreshing = false;
|
||||
// Token refresh failed, clear tokens
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
await secureStorage.delete(key: AppConstants.accessTokenKey);
|
||||
await secureStorage.delete(key: AppConstants.refreshTokenKey);
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
114
lib/core/router/app_router.dart
Normal file
114
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../features/admin/presentation/screens/admin_home_screen.dart';
|
||||
import '../../features/admin/presentation/screens/system_settings_screen.dart';
|
||||
import '../../features/admin/presentation/screens/user_management_screen.dart';
|
||||
import '../../features/auth/presentation/screens/login_screen.dart';
|
||||
import '../../features/auth/presentation/screens/register_screen.dart';
|
||||
import '../../features/dashboard/presentation/screens/dashboard_screen.dart';
|
||||
import '../../features/user/presentation/screens/user_home_screen.dart';
|
||||
import '../../features/user/presentation/screens/user_profile_screen.dart';
|
||||
import '../../shared/providers/auth_provider.dart';
|
||||
import '../../shared/widgets/app_scaffold.dart';
|
||||
import 'auth_guard.dart';
|
||||
import 'route_names.dart';
|
||||
|
||||
part 'app_router.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GoRouter appRouter(Ref ref) {
|
||||
final authGuard = AuthGuard(ref);
|
||||
final authState = ref.watch(authStateProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: RoutePaths.login,
|
||||
debugLogDiagnostics: true,
|
||||
refreshListenable: _GoRouterRefreshStream(ref, authState),
|
||||
redirect: (context, state) {
|
||||
final location = state.uri.toString();
|
||||
return authGuard.redirect(context, location);
|
||||
},
|
||||
routes: [
|
||||
// Auth Routes (비인증)
|
||||
GoRoute(
|
||||
name: RouteNames.login,
|
||||
path: RoutePaths.login,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.register,
|
||||
path: RoutePaths.register,
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
|
||||
// User Shell Route
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(
|
||||
currentPath: state.uri.toString(),
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: RouteNames.userHome,
|
||||
path: RoutePaths.userHome,
|
||||
builder: (context, state) => const UserHomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.userProfile,
|
||||
path: RoutePaths.userProfile,
|
||||
builder: (context, state) => const UserProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.userDashboard,
|
||||
path: RoutePaths.userDashboard,
|
||||
builder: (context, state) => const DashboardScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Admin Shell Route
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(
|
||||
currentPath: state.uri.toString(),
|
||||
isAdmin: true,
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: RouteNames.adminHome,
|
||||
path: RoutePaths.adminHome,
|
||||
builder: (context, state) => const AdminHomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.adminUsers,
|
||||
path: RoutePaths.adminUsers,
|
||||
builder: (context, state) => const UserManagementScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.adminDashboard,
|
||||
path: RoutePaths.adminDashboard,
|
||||
builder: (context, state) => const DashboardScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.adminSettings,
|
||||
path: RoutePaths.adminSettings,
|
||||
builder: (context, state) => const SystemSettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// GoRouter에서 Riverpod 상태 변경 시 리프레시하기 위한 Listenable 래퍼
|
||||
class _GoRouterRefreshStream extends ChangeNotifier {
|
||||
_GoRouterRefreshStream(this.ref, dynamic _) {
|
||||
// authState 변경 시 GoRouter refresh 트리거
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final Ref ref;
|
||||
}
|
||||
64
lib/core/router/auth_guard.dart
Normal file
64
lib/core/router/auth_guard.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../shared/models/user_role.dart';
|
||||
import '../../shared/providers/auth_provider.dart';
|
||||
import 'route_names.dart';
|
||||
|
||||
/// 인증/역할 기반 라우트 가드
|
||||
class AuthGuard {
|
||||
const AuthGuard(this.ref);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
/// GoRouter redirect 콜백
|
||||
String? redirect(BuildContext context, String location) {
|
||||
final authState = ref.read(authStateProvider);
|
||||
|
||||
return authState.when(
|
||||
data: (user) {
|
||||
final isLoggedIn = user != null;
|
||||
final isAuthRoute =
|
||||
location.startsWith(RoutePaths.login) ||
|
||||
location.startsWith(RoutePaths.register);
|
||||
|
||||
// 비로그인 사용자가 인증 페이지 외 접근 시 → 로그인으로
|
||||
if (!isLoggedIn && !isAuthRoute) {
|
||||
return RoutePaths.login;
|
||||
}
|
||||
|
||||
// 로그인 사용자가 인증 페이지 접근 시 → 역할에 따라 리다이렉트
|
||||
if (isLoggedIn && isAuthRoute) {
|
||||
return _redirectByRole(user!.role);
|
||||
}
|
||||
|
||||
// 역할 기반 접근 제어
|
||||
if (isLoggedIn) {
|
||||
return _checkRoleAccess(location, user!.role);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
loading: () => null,
|
||||
error: (_, __) => RoutePaths.login,
|
||||
);
|
||||
}
|
||||
|
||||
/// 역할에 따른 기본 페이지 리다이렉트
|
||||
String _redirectByRole(UserRole role) {
|
||||
return switch (role) {
|
||||
UserRole.admin => RoutePaths.adminHome,
|
||||
UserRole.user => RoutePaths.userHome,
|
||||
};
|
||||
}
|
||||
|
||||
/// 역할 기반 접근 제어
|
||||
String? _checkRoleAccess(String location, UserRole role) {
|
||||
// 일반 사용자가 관리자 페이지 접근 시도
|
||||
if (location.startsWith(RoutePaths.admin) && role != UserRole.admin) {
|
||||
return RoutePaths.userHome;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
lib/core/router/route_names.dart
Normal file
39
lib/core/router/route_names.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
/// 라우트 이름 상수
|
||||
abstract final class RouteNames {
|
||||
// Auth
|
||||
static const String login = 'login';
|
||||
static const String register = 'register';
|
||||
|
||||
// User
|
||||
static const String userShell = 'user-shell';
|
||||
static const String userHome = 'user-home';
|
||||
static const String userProfile = 'user-profile';
|
||||
static const String userDashboard = 'user-dashboard';
|
||||
|
||||
// Admin
|
||||
static const String adminShell = 'admin-shell';
|
||||
static const String adminHome = 'admin-home';
|
||||
static const String adminUsers = 'admin-users';
|
||||
static const String adminDashboard = 'admin-dashboard';
|
||||
static const String adminSettings = 'admin-settings';
|
||||
}
|
||||
|
||||
/// 라우트 경로 상수
|
||||
abstract final class RoutePaths {
|
||||
// Auth
|
||||
static const String login = '/login';
|
||||
static const String register = '/register';
|
||||
|
||||
// User
|
||||
static const String user = '/user';
|
||||
static const String userHome = '/user/home';
|
||||
static const String userProfile = '/user/profile';
|
||||
static const String userDashboard = '/user/dashboard';
|
||||
|
||||
// Admin
|
||||
static const String admin = '/admin';
|
||||
static const String adminHome = '/admin/home';
|
||||
static const String adminUsers = '/admin/users';
|
||||
static const String adminDashboard = '/admin/dashboard';
|
||||
static const String adminSettings = '/admin/settings';
|
||||
}
|
||||
55
lib/core/storage/local_storage.dart
Normal file
55
lib/core/storage/local_storage.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'local_storage.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
LocalStorage localStorage(Ref ref) {
|
||||
return LocalStorage();
|
||||
}
|
||||
|
||||
class LocalStorage {
|
||||
Future<SharedPreferences> get _prefs async =>
|
||||
SharedPreferences.getInstance();
|
||||
|
||||
Future<void> setString(String key, String value) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
|
||||
Future<String?> getString(String key) async {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
Future<void> setBool(String key, {required bool value}) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setBool(key, value);
|
||||
}
|
||||
|
||||
Future<bool?> getBool(String key) async {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getBool(key);
|
||||
}
|
||||
|
||||
Future<void> setInt(String key, int value) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setInt(key, value);
|
||||
}
|
||||
|
||||
Future<int?> getInt(String key) async {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getInt(key);
|
||||
}
|
||||
|
||||
Future<void> remove(String key) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.clear();
|
||||
}
|
||||
}
|
||||
36
lib/core/storage/secure_storage.dart
Normal file
36
lib/core/storage/secure_storage.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'secure_storage.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SecureStorage secureStorage(Ref ref) {
|
||||
return SecureStorage();
|
||||
}
|
||||
|
||||
class SecureStorage {
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
Future<void> write({required String key, required String value}) async {
|
||||
await _storage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
Future<String?> read({required String key}) async {
|
||||
return _storage.read(key: key);
|
||||
}
|
||||
|
||||
Future<void> delete({required String key}) async {
|
||||
await _storage.delete(key: key);
|
||||
}
|
||||
|
||||
Future<void> deleteAll() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
Future<bool> containsKey({required String key}) async {
|
||||
return _storage.containsKey(key: key);
|
||||
}
|
||||
}
|
||||
44
lib/core/utils/date_utils.dart
Normal file
44
lib/core/utils/date_utils.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
abstract final class AppDateUtils {
|
||||
static final _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
static final _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
static final _timeFormat = DateFormat('HH:mm');
|
||||
static final _koreanDateFormat = DateFormat('yyyy년 MM월 dd일');
|
||||
|
||||
static String formatDate(DateTime date) => _dateFormat.format(date);
|
||||
|
||||
static String formatDateTime(DateTime date) => _dateTimeFormat.format(date);
|
||||
|
||||
static String formatTime(DateTime date) => _timeFormat.format(date);
|
||||
|
||||
static String formatKoreanDate(DateTime date) => _koreanDateFormat.format(date);
|
||||
|
||||
static DateTime? tryParse(String? dateString) {
|
||||
if (dateString == null) return null;
|
||||
try {
|
||||
return DateTime.parse(dateString);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String timeAgo(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays > 365) {
|
||||
return '${diff.inDays ~/ 365}년 전';
|
||||
} else if (diff.inDays > 30) {
|
||||
return '${diff.inDays ~/ 30}개월 전';
|
||||
} else if (diff.inDays > 0) {
|
||||
return '${diff.inDays}일 전';
|
||||
} else if (diff.inHours > 0) {
|
||||
return '${diff.inHours}시간 전';
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return '${diff.inMinutes}분 전';
|
||||
} else {
|
||||
return '방금 전';
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/core/utils/extensions.dart
Normal file
42
lib/core/utils/extensions.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension BuildContextExtension on BuildContext {
|
||||
ThemeData get theme => Theme.of(this);
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
MediaQueryData get mediaQuery => MediaQuery.of(this);
|
||||
Size get screenSize => MediaQuery.sizeOf(this);
|
||||
double get screenWidth => screenSize.width;
|
||||
double get screenHeight => screenSize.height;
|
||||
bool get isMobile => screenWidth < 600;
|
||||
bool get isTablet => screenWidth >= 600 && screenWidth < 1200;
|
||||
bool get isDesktop => screenWidth >= 1200;
|
||||
|
||||
void showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? colorScheme.error : null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
String get capitalize =>
|
||||
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
|
||||
|
||||
bool get isValidEmail => RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
).hasMatch(this);
|
||||
|
||||
bool get isValidPassword => length >= 8;
|
||||
}
|
||||
|
||||
extension DateTimeExtension on DateTime {
|
||||
String get toFormattedString => '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||
|
||||
String get toFormattedDateTime =>
|
||||
'$toFormattedString ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
45
lib/core/utils/validators.dart
Normal file
45
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
abstract final class Validators {
|
||||
static String? email(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '이메일을 입력해주세요';
|
||||
}
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
return '올바른 이메일 형식이 아닙니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? password(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '비밀번호를 입력해주세요';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return '비밀번호는 8자 이상이어야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? required(String? value, [String? fieldName]) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '${fieldName ?? '이 필드'}를 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? minLength(String? value, int min, [String? fieldName]) {
|
||||
if (value == null || value.length < min) {
|
||||
return '${fieldName ?? '이 필드'}는 $min자 이상이어야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? maxLength(String? value, int max, [String? fieldName]) {
|
||||
if (value != null && value.length > max) {
|
||||
return '${fieldName ?? '이 필드'}는 $max자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user