초기 커밋

This commit is contained in:
2026-03-01 07:55:59 +09:00
commit b0262d6bab
67 changed files with 4660 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 '방금 전';
}
}
}

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

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