commit b0262d6babd893a8f0587741757dcaefebdb9b6e Author: basilro Date: Sun Mar 1 07:55:59 2026 +0900 초기 커밋 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1c76139 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter --version:*)", + "Bash(where:*)", + "WebSearch", + "WebFetch(domain:pub.dev)", + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(ls:*)", + "Bash(while read:*)", + "Bash(do rmdir \"$d\")", + "Bash(echo:*)", + "Bash(done)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..845e082 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +API_BASE_URL=http://localhost:8000/api/v1 +WS_BASE_URL=ws://localhost:8000/ws +ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ade486 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VS Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Environment +.env +.env.* +!.env.example + +# Generated files +*.g.dart +*.freezed.dart +lib/generated/ diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1bc2f5d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "lib/generated/**" + - "build/**" + errors: + invalid_annotation_target: ignore + +linter: + rules: + public_member_api_docs: false + lines_longer_than_80_chars: false + flutter_style_todos: false + one_member_abstracts: false diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..c4eef32 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:responsive_framework/responsive_framework.dart'; + +import '../core/router/app_router.dart'; +import 'app_providers.dart'; +import 'theme/app_theme.dart'; + +/// 앱 루트 위젯 +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + final themeMode = ref.watch(themeModeNotifierProvider); + + return MaterialApp.router( + title: 'Flutter Frame', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: themeMode, + routerConfig: router, + builder: (context, child) => ResponsiveBreakpoints.builder( + child: child!, + breakpoints: const [ + Breakpoint(start: 0, end: 450, name: MOBILE), + Breakpoint(start: 451, end: 800, name: TABLET), + Breakpoint(start: 801, end: 1920, name: DESKTOP), + Breakpoint(start: 1921, end: double.infinity, name: '4K'), + ], + ), + ); + } +} diff --git a/lib/app/app_providers.dart b/lib/app/app_providers.dart new file mode 100644 index 0000000..1331dc2 --- /dev/null +++ b/lib/app/app_providers.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'app_providers.g.dart'; + +/// 테마 모드 상태 관리 +@riverpod +class ThemeModeNotifier extends _$ThemeModeNotifier { + @override + ThemeMode build() => ThemeMode.system; + + void setThemeMode(ThemeMode mode) { + state = mode; + } + + void toggleTheme() { + state = switch (state) { + ThemeMode.light => ThemeMode.dark, + ThemeMode.dark => ThemeMode.light, + ThemeMode.system => ThemeMode.dark, + }; + } +} diff --git a/lib/app/theme/app_colors.dart b/lib/app/theme/app_colors.dart new file mode 100644 index 0000000..18e8b09 --- /dev/null +++ b/lib/app/theme/app_colors.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +/// 앱 커스텀 컬러 상수 +abstract final class AppColors { + // Primary + static const Color primary = Color(0xFF1E40AF); + static const Color primaryLight = Color(0xFF3B82F6); + static const Color primaryDark = Color(0xFF1E3A8A); + + // Secondary + static const Color secondary = Color(0xFF7C3AED); + static const Color secondaryLight = Color(0xFF8B5CF6); + static const Color secondaryDark = Color(0xFF6D28D9); + + // Status + static const Color success = Color(0xFF16A34A); + static const Color warning = Color(0xFFEAB308); + static const Color error = Color(0xFFDC2626); + static const Color info = Color(0xFF0EA5E9); + + // Neutral + static const Color background = Color(0xFFF8FAFC); + static const Color surface = Color(0xFFFFFFFF); + static const Color textPrimary = Color(0xFF0F172A); + static const Color textSecondary = Color(0xFF64748B); + static const Color border = Color(0xFFE2E8F0); + static const Color divider = Color(0xFFF1F5F9); + + // Dark Mode + static const Color darkBackground = Color(0xFF0F172A); + static const Color darkSurface = Color(0xFF1E293B); + static const Color darkTextPrimary = Color(0xFFF8FAFC); + static const Color darkTextSecondary = Color(0xFF94A3B8); + static const Color darkBorder = Color(0xFF334155); +} diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart new file mode 100644 index 0000000..bba5de8 --- /dev/null +++ b/lib/app/theme/app_theme.dart @@ -0,0 +1,62 @@ +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; + +/// FlexColorScheme 기반 라이트/다크 테마 설정 +abstract final class AppTheme { + /// 라이트 테마 + static ThemeData get light => FlexThemeData.light( + scheme: FlexScheme.indigo, + surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold, + blendLevel: 7, + subThemesData: const FlexSubThemesData( + blendOnLevel: 10, + blendOnColors: false, + useTextTheme: true, + useM2StyleDividerInM3: true, + alignedDropdown: true, + useInputDecoratorThemeInDialogs: true, + inputDecoratorBorderType: FlexInputBorderType.outline, + inputDecoratorRadius: 8, + chipRadius: 8, + cardRadius: 12, + dialogRadius: 16, + bottomSheetRadius: 16, + elevatedButtonRadius: 8, + outlinedButtonRadius: 8, + filledButtonRadius: 8, + textButtonRadius: 8, + fabRadius: 16, + ), + visualDensity: FlexColorScheme.comfortablePlatformDensity, + useMaterial3: true, + fontFamily: 'Pretendard', + ); + + /// 다크 테마 + static ThemeData get dark => FlexThemeData.dark( + scheme: FlexScheme.indigo, + surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold, + blendLevel: 13, + subThemesData: const FlexSubThemesData( + blendOnLevel: 20, + useTextTheme: true, + useM2StyleDividerInM3: true, + alignedDropdown: true, + useInputDecoratorThemeInDialogs: true, + inputDecoratorBorderType: FlexInputBorderType.outline, + inputDecoratorRadius: 8, + chipRadius: 8, + cardRadius: 12, + dialogRadius: 16, + bottomSheetRadius: 16, + elevatedButtonRadius: 8, + outlinedButtonRadius: 8, + filledButtonRadius: 8, + textButtonRadius: 8, + fabRadius: 16, + ), + visualDensity: FlexColorScheme.comfortablePlatformDensity, + useMaterial3: true, + fontFamily: 'Pretendard', + ); +} diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart new file mode 100644 index 0000000..e9d47bc --- /dev/null +++ b/lib/core/constants/api_constants.dart @@ -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); +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..91a4b1f --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -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); +} diff --git a/lib/core/env/env.dart b/lib/core/env/env.dart new file mode 100644 index 0000000..2289622 --- /dev/null +++ b/lib/core/env/env.dart @@ -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; +} diff --git a/lib/core/error/exceptions.dart b/lib/core/error/exceptions.dart new file mode 100644 index 0000000..63c3eb7 --- /dev/null +++ b/lib/core/error/exceptions.dart @@ -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)'; +} diff --git a/lib/core/error/failures.dart b/lib/core/error/failures.dart new file mode 100644 index 0000000..e3e71db --- /dev/null +++ b/lib/core/error/failures.dart @@ -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; +} diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart new file mode 100644 index 0000000..10022a4 --- /dev/null +++ b/lib/core/logging/app_logger.dart @@ -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, + ), + ), + ); +} diff --git a/lib/core/network/auth_interceptor.dart b/lib/core/network/auth_interceptor.dart new file mode 100644 index 0000000..8f71c32 --- /dev/null +++ b/lib/core/network/auth_interceptor.dart @@ -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 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); + } +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..9f6af27 --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -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; +} diff --git a/lib/core/network/socket_manager.dart b/lib/core/network/socket_manager.dart new file mode 100644 index 0000000..a981538 --- /dev/null +++ b/lib/core/network/socket_manager.dart @@ -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>.broadcast(); + bool _isConnected = false; + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + + Stream> get messageStream => _messageController.stream; + bool get isConnected => _isConnected; + + Future 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; + _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 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; + } +} diff --git a/lib/core/network/token_refresh_interceptor.dart b/lib/core/network/token_refresh_interceptor.dart new file mode 100644 index 0000000..720e435 --- /dev/null +++ b/lib/core/network/token_refresh_interceptor.dart @@ -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 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); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..5d4e17a --- /dev/null +++ b/lib/core/router/app_router.dart @@ -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; +} diff --git a/lib/core/router/auth_guard.dart b/lib/core/router/auth_guard.dart new file mode 100644 index 0000000..165e530 --- /dev/null +++ b/lib/core/router/auth_guard.dart @@ -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; + } +} diff --git a/lib/core/router/route_names.dart b/lib/core/router/route_names.dart new file mode 100644 index 0000000..f67a006 --- /dev/null +++ b/lib/core/router/route_names.dart @@ -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'; +} diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart new file mode 100644 index 0000000..5866a13 --- /dev/null +++ b/lib/core/storage/local_storage.dart @@ -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 get _prefs async => + SharedPreferences.getInstance(); + + Future setString(String key, String value) async { + final prefs = await _prefs; + await prefs.setString(key, value); + } + + Future getString(String key) async { + final prefs = await _prefs; + return prefs.getString(key); + } + + Future setBool(String key, {required bool value}) async { + final prefs = await _prefs; + await prefs.setBool(key, value); + } + + Future getBool(String key) async { + final prefs = await _prefs; + return prefs.getBool(key); + } + + Future setInt(String key, int value) async { + final prefs = await _prefs; + await prefs.setInt(key, value); + } + + Future getInt(String key) async { + final prefs = await _prefs; + return prefs.getInt(key); + } + + Future remove(String key) async { + final prefs = await _prefs; + await prefs.remove(key); + } + + Future clear() async { + final prefs = await _prefs; + await prefs.clear(); + } +} diff --git a/lib/core/storage/secure_storage.dart b/lib/core/storage/secure_storage.dart new file mode 100644 index 0000000..e71334b --- /dev/null +++ b/lib/core/storage/secure_storage.dart @@ -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 write({required String key, required String value}) async { + await _storage.write(key: key, value: value); + } + + Future read({required String key}) async { + return _storage.read(key: key); + } + + Future delete({required String key}) async { + await _storage.delete(key: key); + } + + Future deleteAll() async { + await _storage.deleteAll(); + } + + Future containsKey({required String key}) async { + return _storage.containsKey(key: key); + } +} diff --git a/lib/core/utils/date_utils.dart b/lib/core/utils/date_utils.dart new file mode 100644 index 0000000..d6ac426 --- /dev/null +++ b/lib/core/utils/date_utils.dart @@ -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 '방금 전'; + } + } +} diff --git a/lib/core/utils/extensions.dart b/lib/core/utils/extensions.dart new file mode 100644 index 0000000..cbba6cc --- /dev/null +++ b/lib/core/utils/extensions.dart @@ -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')}'; +} diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart new file mode 100644 index 0000000..0c120ef --- /dev/null +++ b/lib/core/utils/validators.dart @@ -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; + } +} diff --git a/lib/features/admin/presentation/screens/admin_home_screen.dart b/lib/features/admin/presentation/screens/admin_home_screen.dart new file mode 100644 index 0000000..534671e --- /dev/null +++ b/lib/features/admin/presentation/screens/admin_home_screen.dart @@ -0,0 +1,238 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/router/route_names.dart'; +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/widgets/chart_widgets/line_chart_widget.dart'; + +class AdminHomeScreen extends StatelessWidget { + const AdminHomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '관리자 대시보드', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(8), + Text( + '시스템 현황을 한눈에 확인하세요', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + const Gap(24), + + // 통계 카드 + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = context.isDesktop + ? 4 + : context.isTablet + ? 2 + : 1; + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 1.8, + children: const [ + _AdminStatCard( + title: '전체 사용자', + value: '1,234', + change: '+12%', + isPositive: true, + icon: Icons.people, + color: Colors.blue, + ), + _AdminStatCard( + title: '활성 세션', + value: '89', + change: '+5%', + isPositive: true, + icon: Icons.devices, + color: Colors.green, + ), + _AdminStatCard( + title: 'API 요청/분', + value: '2,456', + change: '-3%', + isPositive: false, + icon: Icons.api, + color: Colors.orange, + ), + _AdminStatCard( + title: '시스템 오류', + value: '3', + change: '-50%', + isPositive: true, + icon: Icons.error_outline, + color: Colors.red, + ), + ], + ); + }, + ), + const Gap(24), + + // 차트 영역 + SizedBox( + height: 300, + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: LineChartWidget( + title: '주간 사용자 활동', + spots: const [ + FlSpot(0, 30), + FlSpot(1, 45), + FlSpot(2, 38), + FlSpot(3, 60), + FlSpot(4, 55), + FlSpot(5, 70), + FlSpot(6, 65), + ], + bottomTitles: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + ['월', '화', '수', '목', '금', '토', '일'] + [value.toInt() % 7], + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ), + ), + ), + const Gap(24), + + // 빠른 링크 + Text( + '빠른 액세스', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _QuickAction( + icon: Icons.people, + label: '사용자 관리', + onTap: () => context.goNamed(RouteNames.adminUsers), + ), + _QuickAction( + icon: Icons.dashboard, + label: '대시보드', + onTap: () => context.goNamed(RouteNames.adminDashboard), + ), + _QuickAction( + icon: Icons.settings, + label: '시스템 설정', + onTap: () => context.goNamed(RouteNames.adminSettings), + ), + ], + ), + ], + ), + ); + } +} + +class _AdminStatCard extends StatelessWidget { + const _AdminStatCard({ + required this.title, + required this.value, + required this.change, + required this.isPositive, + required this.icon, + required this.color, + }); + + final String title; + final String value; + final String change; + final bool isPositive; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Icon(icon, color: color, size: 24), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(8), + Text( + change, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isPositive ? Colors.green : Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _QuickAction extends StatelessWidget { + const _QuickAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: Icon(icon), + label: Text(label), + onPressed: onTap, + ); + } +} diff --git a/lib/features/admin/presentation/screens/system_settings_screen.dart b/lib/features/admin/presentation/screens/system_settings_screen.dart new file mode 100644 index 0000000..18e5b6b --- /dev/null +++ b/lib/features/admin/presentation/screens/system_settings_screen.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; + +import '../../../../app/app_providers.dart'; +import '../../../../core/utils/extensions.dart'; + +class SystemSettingsScreen extends ConsumerWidget { + const SystemSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeNotifierProvider); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '시스템 설정', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(8), + Text( + '시스템 환경을 설정하세요', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + const Gap(24), + + // 일반 설정 + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Column( + children: [ + _SettingsSection( + title: '일반', + children: [ + _SettingsTile( + icon: Icons.brightness_6, + title: '테마', + subtitle: _themeLabel(themeMode), + trailing: SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode), + label: Text('라이트'), + ), + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto), + label: Text('시스템'), + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode), + label: Text('다크'), + ), + ], + selected: {themeMode}, + onSelectionChanged: (modes) { + ref + .read(themeModeNotifierProvider.notifier) + .setThemeMode(modes.first); + }, + ), + ), + _SettingsTile( + icon: Icons.language, + title: '언어', + subtitle: '한국어', + trailing: const Icon(Icons.chevron_right), + onTap: () { + // TODO: 언어 설정 + }, + ), + ], + ), + const Gap(16), + + _SettingsSection( + title: '알림', + children: [ + _SettingsTile( + icon: Icons.notifications, + title: '푸시 알림', + subtitle: '활성화됨', + trailing: Switch( + value: true, + onChanged: (value) { + // TODO: 알림 설정 + }, + ), + ), + _SettingsTile( + icon: Icons.email, + title: '이메일 알림', + subtitle: '활성화됨', + trailing: Switch( + value: true, + onChanged: (value) { + // TODO: 이메일 알림 설정 + }, + ), + ), + ], + ), + const Gap(16), + + _SettingsSection( + title: 'API 설정', + children: [ + _SettingsTile( + icon: Icons.link, + title: 'API Base URL', + subtitle: 'http://localhost:8000/api/v1', + trailing: const Icon(Icons.chevron_right), + onTap: () { + // TODO: API URL 설정 + }, + ), + _SettingsTile( + icon: Icons.timer, + title: '요청 타임아웃', + subtitle: '15초', + trailing: const Icon(Icons.chevron_right), + onTap: () { + // TODO: 타임아웃 설정 + }, + ), + ], + ), + const Gap(16), + + _SettingsSection( + title: '정보', + children: [ + const _SettingsTile( + icon: Icons.info_outline, + title: '앱 버전', + subtitle: '1.0.0', + ), + const _SettingsTile( + icon: Icons.code, + title: 'Flutter', + subtitle: 'SDK 3.8.0', + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + String _themeLabel(ThemeMode mode) { + return switch (mode) { + ThemeMode.light => '라이트 모드', + ThemeMode.dark => '다크 모드', + ThemeMode.system => '시스템 설정', + }; + } +} + +class _SettingsSection extends StatelessWidget { + const _SettingsSection({ + required this.title, + required this.children, + }); + + final String title; + final List children; + + @override + Widget build(BuildContext context) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ...children, + ], + ), + ); + } +} + +class _SettingsTile extends StatelessWidget { + const _SettingsTile({ + required this.icon, + required this.title, + required this.subtitle, + this.trailing, + this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final Widget? trailing; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: trailing, + onTap: onTap, + ); + } +} diff --git a/lib/features/admin/presentation/screens/user_management_screen.dart b/lib/features/admin/presentation/screens/user_management_screen.dart new file mode 100644 index 0000000..2c1a1df --- /dev/null +++ b/lib/features/admin/presentation/screens/user_management_screen.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; +import 'package:pluto_grid/pluto_grid.dart'; + +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/widgets/confirm_dialog.dart'; + +class UserManagementScreen extends ConsumerStatefulWidget { + const UserManagementScreen({super.key}); + + @override + ConsumerState createState() => + _UserManagementScreenState(); +} + +class _UserManagementScreenState extends ConsumerState { + late PlutoGridStateManager stateManager; + + // Mock 데이터 + final List> _mockUsers = List.generate( + 20, + (i) => { + 'id': '${i + 1}', + 'name': '사용자 ${i + 1}', + 'email': 'user${i + 1}@example.com', + 'role': i == 0 ? 'admin' : 'user', + 'status': i % 5 == 0 ? '비활성' : '활성', + 'created_at': '2025-01-${(i + 1).toString().padLeft(2, '0')}', + }, + ); + + List get _columns => [ + PlutoColumn( + title: 'ID', + field: 'id', + type: PlutoColumnType.text(), + width: 60, + enableEditingMode: false, + ), + PlutoColumn( + title: '이름', + field: 'name', + type: PlutoColumnType.text(), + width: 150, + ), + PlutoColumn( + title: '이메일', + field: 'email', + type: PlutoColumnType.text(), + width: 250, + ), + PlutoColumn( + title: '역할', + field: 'role', + type: PlutoColumnType.select(['admin', 'user']), + width: 100, + ), + PlutoColumn( + title: '상태', + field: 'status', + type: PlutoColumnType.select(['활성', '비활성']), + width: 100, + ), + PlutoColumn( + title: '가입일', + field: 'created_at', + type: PlutoColumnType.text(), + width: 150, + enableEditingMode: false, + ), + ]; + + List get _rows => _mockUsers + .map( + (user) => PlutoRow( + cells: { + 'id': PlutoCell(value: user['id']), + 'name': PlutoCell(value: user['name']), + 'email': PlutoCell(value: user['email']), + 'role': PlutoCell(value: user['role']), + 'status': PlutoCell(value: user['status']), + 'created_at': PlutoCell(value: user['created_at']), + }, + ), + ) + .toList(); + + void _showCreateUserDialog() { + final nameController = TextEditingController(); + final emailController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('새 사용자 추가'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: '이름', + hintText: '사용자 이름 입력', + ), + ), + const Gap(16), + TextField( + controller: emailController, + decoration: const InputDecoration( + labelText: '이메일', + hintText: 'email@example.com', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + FilledButton( + onPressed: () { + // TODO: API 연동 + Navigator.pop(context); + if (mounted) { + context.showSnackBar('사용자가 추가되었습니다'); + } + }, + child: const Text('추가'), + ), + ], + ), + ); + } + + Future _deleteUser(PlutoRow row) async { + final confirmed = await ConfirmDialog.show( + context, + title: '사용자 삭제', + message: '정말로 이 사용자를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + confirmLabel: '삭제', + isDestructive: true, + ); + + if (confirmed == true) { + stateManager.removeRows([row]); + if (mounted) { + context.showSnackBar('사용자가 삭제되었습니다'); + } + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '사용자 관리', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(4), + Text( + '사용자를 조회하고 관리하세요', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + FilledButton.icon( + onPressed: _showCreateUserDialog, + icon: const Icon(Icons.person_add), + label: const Text('새 사용자'), + ), + ], + ), + const Gap(24), + + // 데이터 그리드 + Expanded( + child: Card( + clipBehavior: Clip.antiAlias, + child: PlutoGrid( + columns: _columns, + rows: _rows, + onLoaded: (event) { + stateManager = event.stateManager; + }, + onRowDoubleTap: (event) { + // TODO: 사용자 상세 정보 다이얼로그 + }, + configuration: PlutoGridConfiguration( + style: PlutoGridStyleConfig( + gridBackgroundColor: + context.colorScheme.surface, + rowColor: context.colorScheme.surface, + activatedColor: + context.colorScheme.primaryContainer, + activatedBorderColor: context.colorScheme.primary, + gridBorderColor: + context.colorScheme.outlineVariant, + borderColor: + context.colorScheme.outlineVariant, + cellTextStyle: + context.textTheme.bodyMedium!, + columnTextStyle: + context.textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + columnSize: const PlutoGridColumnSizeConfig( + autoSizeMode: PlutoAutoSizeMode.scale, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/data/datasources/auth_remote_source.dart b/lib/features/auth/data/datasources/auth_remote_source.dart new file mode 100644 index 0000000..8ccfdd0 --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_source.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/constants/api_constants.dart'; +import '../models/login_request.dart'; +import '../models/register_request.dart'; +import '../models/token_response.dart'; + +part 'auth_remote_source.g.dart'; + +@RestApi() +abstract class AuthRemoteSource { + factory AuthRemoteSource(Dio dio) = _AuthRemoteSource; + + @POST(ApiConstants.login) + Future login(@Body() LoginRequest request); + + @POST(ApiConstants.register) + Future register(@Body() RegisterRequest request); + + @POST(ApiConstants.logout) + Future logout(); + + @POST(ApiConstants.refreshToken) + Future refreshToken( + @Body() Map body, + ); +} diff --git a/lib/features/auth/data/models/login_request.dart b/lib/features/auth/data/models/login_request.dart new file mode 100644 index 0000000..758fcb9 --- /dev/null +++ b/lib/features/auth/data/models/login_request.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'login_request.freezed.dart'; +part 'login_request.g.dart'; + +@freezed +class LoginRequest with _$LoginRequest { + const factory LoginRequest({ + required String email, + required String password, + }) = _LoginRequest; + + factory LoginRequest.fromJson(Map json) => + _$LoginRequestFromJson(json); +} diff --git a/lib/features/auth/data/models/register_request.dart b/lib/features/auth/data/models/register_request.dart new file mode 100644 index 0000000..c9d4e72 --- /dev/null +++ b/lib/features/auth/data/models/register_request.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'register_request.freezed.dart'; +part 'register_request.g.dart'; + +@freezed +class RegisterRequest with _$RegisterRequest { + const factory RegisterRequest({ + required String email, + required String password, + required String name, + }) = _RegisterRequest; + + factory RegisterRequest.fromJson(Map json) => + _$RegisterRequestFromJson(json); +} diff --git a/lib/features/auth/data/models/token_response.dart b/lib/features/auth/data/models/token_response.dart new file mode 100644 index 0000000..e2bf063 --- /dev/null +++ b/lib/features/auth/data/models/token_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'token_response.freezed.dart'; +part 'token_response.g.dart'; + +@freezed +class TokenResponse with _$TokenResponse { + const factory TokenResponse({ + @JsonKey(name: 'access_token') required String accessToken, + @JsonKey(name: 'refresh_token') required String refreshToken, + @JsonKey(name: 'token_type') @Default('bearer') String tokenType, + }) = _TokenResponse; + + factory TokenResponse.fromJson(Map json) => + _$TokenResponseFromJson(json); +} diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..4805d90 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +import 'package:talker/talker.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/error/exceptions.dart'; +import '../../../../core/storage/secure_storage.dart'; +import '../../../../shared/models/user_role.dart'; +import '../../domain/entities/user.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../datasources/auth_remote_source.dart'; +import '../models/login_request.dart'; +import '../models/register_request.dart'; + +class AuthRepositoryImpl implements AuthRepository { + AuthRepositoryImpl({ + required this.remoteSource, + required this.secureStorage, + required this.talker, + }); + + final AuthRemoteSource remoteSource; + final SecureStorage secureStorage; + final Talker talker; + + @override + Future login({ + required String email, + required String password, + }) async { + try { + final response = await remoteSource.login( + LoginRequest(email: email, password: password), + ); + + await _saveTokens(response.accessToken, response.refreshToken); + + // 로그인 후 사용자 정보 조회 + final user = await getCurrentUser(); + if (user == null) { + throw const ServerException( + message: '사용자 정보를 가져올 수 없습니다', + statusCode: 500, + ); + } + return user; + } catch (e) { + talker.error('Login failed', e); + rethrow; + } + } + + @override + Future register({ + required String email, + required String password, + required String name, + }) async { + try { + final response = await remoteSource.register( + RegisterRequest(email: email, password: password, name: name), + ); + + await _saveTokens(response.accessToken, response.refreshToken); + + return User( + id: '', + email: email, + name: name, + role: UserRole.user, + ); + } catch (e) { + talker.error('Register failed', e); + rethrow; + } + } + + @override + Future logout() async { + try { + await remoteSource.logout(); + } catch (_) { + // 서버 로그아웃 실패해도 로컬 토큰은 삭제 + } finally { + await secureStorage.delete(key: AppConstants.accessTokenKey); + await secureStorage.delete(key: AppConstants.refreshTokenKey); + await secureStorage.delete(key: AppConstants.userKey); + } + } + + @override + Future getCurrentUser() async { + try { + final userData = await secureStorage.read(key: AppConstants.userKey); + if (userData != null) { + return User.fromJson( + jsonDecode(userData) as Map, + ); + } + return null; + } catch (e) { + talker.error('Get current user failed', e); + return null; + } + } + + @override + Future isLoggedIn() async { + final token = await secureStorage.read(key: AppConstants.accessTokenKey); + return token != null; + } + + Future _saveTokens(String accessToken, String refreshToken) async { + await secureStorage.write( + key: AppConstants.accessTokenKey, + value: accessToken, + ); + await secureStorage.write( + key: AppConstants.refreshTokenKey, + value: refreshToken, + ); + } +} diff --git a/lib/features/auth/domain/entities/user.dart b/lib/features/auth/domain/entities/user.dart new file mode 100644 index 0000000..45c875e --- /dev/null +++ b/lib/features/auth/domain/entities/user.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../shared/models/user_role.dart'; + +part 'user.freezed.dart'; +part 'user.g.dart'; + +@freezed +class User with _$User { + const factory User({ + required String id, + required String email, + required String name, + @Default(UserRole.user) UserRole role, + String? avatarUrl, + DateTime? createdAt, + DateTime? lastLoginAt, + @Default(true) bool isActive, + }) = _User; + + factory User.fromJson(Map json) => _$UserFromJson(json); +} diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..2f78a6c --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,20 @@ +import '../entities/user.dart'; + +abstract class AuthRepository { + Future login({ + required String email, + required String password, + }); + + Future register({ + required String email, + required String password, + required String name, + }); + + Future logout(); + + Future getCurrentUser(); + + Future isLoggedIn(); +} diff --git a/lib/features/auth/domain/usecases/login_usecase.dart b/lib/features/auth/domain/usecases/login_usecase.dart new file mode 100644 index 0000000..e9d0b2f --- /dev/null +++ b/lib/features/auth/domain/usecases/login_usecase.dart @@ -0,0 +1,15 @@ +import '../../domain/entities/user.dart'; +import '../../domain/repositories/auth_repository.dart'; + +class LoginUseCase { + LoginUseCase({required this.repository}); + + final AuthRepository repository; + + Future call({ + required String email, + required String password, + }) { + return repository.login(email: email, password: password); + } +} diff --git a/lib/features/auth/domain/usecases/logout_usecase.dart b/lib/features/auth/domain/usecases/logout_usecase.dart new file mode 100644 index 0000000..ea1f45f --- /dev/null +++ b/lib/features/auth/domain/usecases/logout_usecase.dart @@ -0,0 +1,9 @@ +import '../../domain/repositories/auth_repository.dart'; + +class LogoutUseCase { + LogoutUseCase({required this.repository}); + + final AuthRepository repository; + + Future call() => repository.logout(); +} diff --git a/lib/features/auth/domain/usecases/register_usecase.dart b/lib/features/auth/domain/usecases/register_usecase.dart new file mode 100644 index 0000000..e977d25 --- /dev/null +++ b/lib/features/auth/domain/usecases/register_usecase.dart @@ -0,0 +1,20 @@ +import '../../domain/entities/user.dart'; +import '../../domain/repositories/auth_repository.dart'; + +class RegisterUseCase { + RegisterUseCase({required this.repository}); + + final AuthRepository repository; + + Future call({ + required String email, + required String password, + required String name, + }) { + return repository.register( + email: email, + password: password, + name: name, + ); + } +} diff --git a/lib/features/auth/presentation/providers/auth_providers.dart b/lib/features/auth/presentation/providers/auth_providers.dart new file mode 100644 index 0000000..ce4277d --- /dev/null +++ b/lib/features/auth/presentation/providers/auth_providers.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/logging/app_logger.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../../core/storage/secure_storage.dart'; +import '../../data/datasources/auth_remote_source.dart'; +import '../../data/repositories/auth_repository_impl.dart'; +import '../../domain/entities/user.dart'; +import '../../domain/repositories/auth_repository.dart'; + +part 'auth_providers.g.dart'; + +@Riverpod(keepAlive: true) +AuthRemoteSource authRemoteSource(Ref ref) { + final dio = ref.read(dioProvider); + return AuthRemoteSource(dio); +} + +@Riverpod(keepAlive: true) +AuthRepository authRepository(Ref ref) { + return AuthRepositoryImpl( + remoteSource: ref.read(authRemoteSourceProvider), + secureStorage: ref.read(secureStorageProvider), + talker: ref.read(appLoggerProvider), + ); +} + +@riverpod +class LoginNotifier extends _$LoginNotifier { + @override + FutureOr build() {} + + Future login({ + required String email, + required String password, + }) async { + state = const AsyncLoading(); + final result = await AsyncValue.guard(() async { + final repository = ref.read(authRepositoryProvider); + return repository.login(email: email, password: password); + }); + + state = result.hasError + ? AsyncError(result.error!, result.stackTrace!) + : const AsyncData(null); + + return result.valueOrNull; + } +} + +@riverpod +class RegisterNotifier extends _$RegisterNotifier { + @override + FutureOr build() {} + + Future register({ + required String email, + required String password, + required String name, + }) async { + state = const AsyncLoading(); + final result = await AsyncValue.guard(() async { + final repository = ref.read(authRepositoryProvider); + return repository.register( + email: email, + password: password, + name: name, + ); + }); + + state = result.hasError + ? AsyncError(result.error!, result.stackTrace!) + : const AsyncData(null); + + return result.valueOrNull; + } +} diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..d53afcd --- /dev/null +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../widgets/login_form.dart'; + +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: const Card( + child: Padding( + padding: EdgeInsets.all(32), + child: LoginForm(), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/screens/register_screen.dart b/lib/features/auth/presentation/screens/register_screen.dart new file mode 100644 index 0000000..d3587e6 --- /dev/null +++ b/lib/features/auth/presentation/screens/register_screen.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../widgets/register_form.dart'; + +class RegisterScreen extends StatelessWidget { + const RegisterScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: const Card( + child: Padding( + padding: EdgeInsets.all(32), + child: RegisterForm(), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/login_form.dart b/lib/features/auth/presentation/widgets/login_form.dart new file mode 100644 index 0000000..88a0a59 --- /dev/null +++ b/lib/features/auth/presentation/widgets/login_form.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:reactive_forms/reactive_forms.dart'; + +import '../../../../core/router/route_names.dart'; +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/providers/auth_provider.dart'; +import '../providers/auth_providers.dart'; + +class LoginForm extends ConsumerStatefulWidget { + const LoginForm({super.key}); + + @override + ConsumerState createState() => _LoginFormState(); +} + +class _LoginFormState extends ConsumerState { + late final FormGroup form; + + @override + void initState() { + super.initState(); + form = FormGroup({ + 'email': FormControl( + validators: [Validators.required, Validators.email], + ), + 'password': FormControl( + validators: [Validators.required, Validators.minLength(8)], + ), + }); + } + + @override + void dispose() { + form.dispose(); + super.dispose(); + } + + Future _onSubmit() async { + if (!form.valid) { + form.markAllAsTouched(); + return; + } + + final email = form.control('email').value as String; + final password = form.control('password').value as String; + + final user = await ref.read(loginNotifierProvider.notifier).login( + email: email, + password: password, + ); + + if (user != null && mounted) { + ref.read(authStateProvider.notifier).setUser(user); + } + } + + @override + Widget build(BuildContext context) { + final loginState = ref.watch(loginNotifierProvider); + + ref.listen(loginNotifierProvider, (_, state) { + if (state.hasError) { + context.showSnackBar( + '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', + isError: true, + ); + } + }); + + return ReactiveForm( + formGroup: form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '로그인', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const Gap(8), + Text( + '계정에 로그인하세요', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const Gap(32), + ReactiveTextField( + formControlName: 'email', + decoration: const InputDecoration( + labelText: '이메일', + hintText: 'email@example.com', + prefixIcon: Icon(Icons.email_outlined), + ), + keyboardType: TextInputType.emailAddress, + validationMessages: { + 'required': (_) => '이메일을 입력해주세요', + 'email': (_) => '올바른 이메일 형식이 아닙니다', + }, + ), + const Gap(16), + ReactiveTextField( + formControlName: 'password', + decoration: const InputDecoration( + labelText: '비밀번호', + hintText: '8자 이상 입력', + prefixIcon: Icon(Icons.lock_outlined), + ), + obscureText: true, + validationMessages: { + 'required': (_) => '비밀번호를 입력해주세요', + 'minLength': (_) => '비밀번호는 8자 이상이어야 합니다', + }, + ), + const Gap(24), + FilledButton( + onPressed: loginState.isLoading ? null : _onSubmit, + child: loginState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('로그인'), + ), + const Gap(16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('계정이 없으신가요?'), + TextButton( + onPressed: () => context.goNamed(RouteNames.register), + child: const Text('회원가입'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/register_form.dart b/lib/features/auth/presentation/widgets/register_form.dart new file mode 100644 index 0000000..e6b5eaf --- /dev/null +++ b/lib/features/auth/presentation/widgets/register_form.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:reactive_forms/reactive_forms.dart'; + +import '../../../../core/router/route_names.dart'; +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/providers/auth_provider.dart'; +import '../providers/auth_providers.dart'; + +class RegisterForm extends ConsumerStatefulWidget { + const RegisterForm({super.key}); + + @override + ConsumerState createState() => _RegisterFormState(); +} + +class _RegisterFormState extends ConsumerState { + late final FormGroup form; + + @override + void initState() { + super.initState(); + form = FormGroup({ + 'name': FormControl( + validators: [Validators.required], + ), + 'email': FormControl( + validators: [Validators.required, Validators.email], + ), + 'password': FormControl( + validators: [Validators.required, Validators.minLength(8)], + ), + 'confirmPassword': FormControl( + validators: [Validators.required], + ), + }, validators: [ + Validators.mustMatch('password', 'confirmPassword'), + ]); + } + + @override + void dispose() { + form.dispose(); + super.dispose(); + } + + Future _onSubmit() async { + if (!form.valid) { + form.markAllAsTouched(); + return; + } + + final name = form.control('name').value as String; + final email = form.control('email').value as String; + final password = form.control('password').value as String; + + final user = await ref.read(registerNotifierProvider.notifier).register( + email: email, + password: password, + name: name, + ); + + if (user != null && mounted) { + ref.read(authStateProvider.notifier).setUser(user); + } + } + + @override + Widget build(BuildContext context) { + final registerState = ref.watch(registerNotifierProvider); + + ref.listen(registerNotifierProvider, (_, state) { + if (state.hasError) { + context.showSnackBar( + '회원가입에 실패했습니다. 입력 정보를 확인해주세요.', + isError: true, + ); + } + }); + + return ReactiveForm( + formGroup: form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '회원가입', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const Gap(8), + Text( + '새 계정을 만드세요', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const Gap(32), + ReactiveTextField( + formControlName: 'name', + decoration: const InputDecoration( + labelText: '이름', + hintText: '홍길동', + prefixIcon: Icon(Icons.person_outlined), + ), + validationMessages: { + 'required': (_) => '이름을 입력해주세요', + }, + ), + const Gap(16), + ReactiveTextField( + formControlName: 'email', + decoration: const InputDecoration( + labelText: '이메일', + hintText: 'email@example.com', + prefixIcon: Icon(Icons.email_outlined), + ), + keyboardType: TextInputType.emailAddress, + validationMessages: { + 'required': (_) => '이메일을 입력해주세요', + 'email': (_) => '올바른 이메일 형식이 아닙니다', + }, + ), + const Gap(16), + ReactiveTextField( + formControlName: 'password', + decoration: const InputDecoration( + labelText: '비밀번호', + hintText: '8자 이상 입력', + prefixIcon: Icon(Icons.lock_outlined), + ), + obscureText: true, + validationMessages: { + 'required': (_) => '비밀번호를 입력해주세요', + 'minLength': (_) => '비밀번호는 8자 이상이어야 합니다', + }, + ), + const Gap(16), + ReactiveTextField( + formControlName: 'confirmPassword', + decoration: const InputDecoration( + labelText: '비밀번호 확인', + hintText: '비밀번호를 다시 입력', + prefixIcon: Icon(Icons.lock_outlined), + ), + obscureText: true, + validationMessages: { + 'required': (_) => '비밀번호 확인을 입력해주세요', + 'mustMatch': (_) => '비밀번호가 일치하지 않습니다', + }, + ), + const Gap(24), + FilledButton( + onPressed: registerState.isLoading ? null : _onSubmit, + child: registerState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('회원가입'), + ), + const Gap(16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('이미 계정이 있으신가요?'), + TextButton( + onPressed: () => context.goNamed(RouteNames.login), + child: const Text('로그인'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/screens/dashboard_screen.dart b/lib/features/dashboard/presentation/screens/dashboard_screen.dart new file mode 100644 index 0000000..1bb8386 --- /dev/null +++ b/lib/features/dashboard/presentation/screens/dashboard_screen.dart @@ -0,0 +1,265 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/widgets/chart_widgets/bar_chart_widget.dart'; +import '../../../../shared/widgets/chart_widgets/pie_chart_widget.dart'; +import '../widgets/monitoring_panel.dart'; +import '../widgets/realtime_chart.dart'; +import '../widgets/stats_card.dart'; + +class DashboardScreen extends StatelessWidget { + const DashboardScreen({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '대시보드', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(8), + Text( + '실시간 데이터와 시스템 현황을 모니터링하세요', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + const Gap(24), + + // 통계 카드 + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = context.isDesktop + ? 4 + : context.isTablet + ? 2 + : 1; + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 1.8, + children: const [ + StatsCard( + title: '총 사용자', + value: '1,234', + icon: Icons.people, + iconColor: Colors.blue, + change: '+12%', + isPositive: true, + subtitle: '전월 대비', + ), + StatsCard( + title: '활성 세션', + value: '89', + icon: Icons.devices, + iconColor: Colors.green, + change: '+5', + isPositive: true, + subtitle: '현재 접속', + ), + StatsCard( + title: '요청 수', + value: '24.5K', + icon: Icons.trending_up, + iconColor: Colors.orange, + change: '+18%', + isPositive: true, + subtitle: '오늘', + ), + StatsCard( + title: '평균 응답시간', + value: '142ms', + icon: Icons.speed, + iconColor: Colors.purple, + change: '-8ms', + isPositive: true, + subtitle: '지난 1시간', + ), + ], + ); + }, + ), + const Gap(24), + + // 실시간 차트 + 모니터링 패널 + LayoutBuilder( + builder: (context, constraints) { + if (context.isDesktop) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded( + flex: 2, + child: SizedBox( + height: 350, + child: RealtimeChart( + title: '실시간 트래픽', + ), + ), + ), + const Gap(16), + const Expanded( + child: MonitoringPanel(), + ), + ], + ); + } + return Column( + children: const [ + SizedBox( + height: 350, + child: RealtimeChart( + title: '실시간 트래픽', + ), + ), + Gap(16), + MonitoringPanel(), + ], + ); + }, + ), + const Gap(24), + + // 바 차트 + 파이 차트 + LayoutBuilder( + builder: (context, constraints) { + final charts = [ + SizedBox( + height: 300, + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: BarChartWidget( + title: '일별 API 요청', + maxY: 100, + barGroups: List.generate( + 7, + (i) => BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: [30, 45, 60, 38, 72, 55, 40][i] + .toDouble(), + color: + context.colorScheme.primary, + width: 16, + borderRadius: + const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ), + ), + bottomTitles: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + ['월', '화', '수', '목', '금', '토', '일'] + [value.toInt() % 7], + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ), + ), + ), + SizedBox( + height: 300, + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: PieChartWidget( + title: '사용자 분포', + sections: [ + PieChartSectionData( + value: 45, + color: Colors.blue, + title: '45%', + radius: 50, + titleStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + PieChartSectionData( + value: 30, + color: Colors.green, + title: '30%', + radius: 50, + titleStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + PieChartSectionData( + value: 15, + color: Colors.orange, + title: '15%', + radius: 50, + titleStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + PieChartSectionData( + value: 10, + color: Colors.purple, + title: '10%', + radius: 50, + titleStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + legendItems: const [ + LegendItem(label: '웹', color: Colors.blue), + LegendItem(label: 'Android', color: Colors.green), + LegendItem(label: 'iOS', color: Colors.orange), + LegendItem(label: '기타', color: Colors.purple), + ], + ), + ), + ), + ), + ]; + + if (context.isDesktop) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: charts[0]), + const Gap(16), + Expanded(child: charts[1]), + ], + ); + } + return Column( + children: [ + charts[0], + const Gap(16), + charts[1], + ], + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/monitoring_panel.dart b/lib/features/dashboard/presentation/widgets/monitoring_panel.dart new file mode 100644 index 0000000..29aea6a --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/monitoring_panel.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class MonitoringPanel extends StatelessWidget { + const MonitoringPanel({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '시스템 모니터링', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(16), + _MonitoringItem( + label: 'CPU 사용률', + value: 0.45, + displayValue: '45%', + color: Colors.blue, + ), + const Gap(12), + _MonitoringItem( + label: '메모리 사용률', + value: 0.62, + displayValue: '62%', + color: Colors.orange, + ), + const Gap(12), + _MonitoringItem( + label: '디스크 사용률', + value: 0.35, + displayValue: '35%', + color: Colors.green, + ), + const Gap(12), + _MonitoringItem( + label: '네트워크 I/O', + value: 0.78, + displayValue: '78 Mbps', + color: Colors.purple, + ), + const Gap(16), + const Divider(), + const Gap(12), + + // 서비스 상태 + Text( + '서비스 상태', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(12), + const _ServiceStatus( + name: 'API 서버', + status: ServiceState.running, + ), + const _ServiceStatus( + name: 'WebSocket', + status: ServiceState.running, + ), + const _ServiceStatus( + name: '데이터베이스', + status: ServiceState.running, + ), + const _ServiceStatus( + name: '캐시 서버', + status: ServiceState.warning, + ), + ], + ), + ), + ); + } +} + +class _MonitoringItem extends StatelessWidget { + const _MonitoringItem({ + required this.label, + required this.value, + required this.displayValue, + required this.color, + }); + + final String label; + final double value; + final String displayValue; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + Text( + displayValue, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const Gap(6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: value, + backgroundColor: color.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(color), + minHeight: 8, + ), + ), + ], + ); + } +} + +enum ServiceState { running, stopped, warning } + +class _ServiceStatus extends StatelessWidget { + const _ServiceStatus({ + required this.name, + required this.status, + }); + + final String name; + final ServiceState status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final (color, label) = switch (status) { + ServiceState.running => (Colors.green, '정상'), + ServiceState.stopped => (Colors.red, '중지'), + ServiceState.warning => (Colors.orange, '경고'), + }; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const Gap(10), + Expanded( + child: Text(name, style: theme.textTheme.bodyMedium), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/realtime_chart.dart b/lib/features/dashboard/presentation/widgets/realtime_chart.dart new file mode 100644 index 0000000..223e810 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/realtime_chart.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class RealtimeChart extends StatefulWidget { + const RealtimeChart({ + this.title = '실시간 데이터', + this.dataStream, + this.maxDataPoints = 30, + this.lineColor, + super.key, + }); + + final String title; + final Stream? dataStream; + final int maxDataPoints; + final Color? lineColor; + + @override + State createState() => _RealtimeChartState(); +} + +class _RealtimeChartState extends State { + final List _spots = []; + StreamSubscription? _subscription; + Timer? _mockTimer; + int _index = 0; + final _random = Random(); + + @override + void initState() { + super.initState(); + if (widget.dataStream != null) { + _subscription = widget.dataStream!.listen(_addDataPoint); + } else { + // Mock 데이터 생성 (WebSocket 미연결 시) + _mockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + _addDataPoint(50 + _random.nextDouble() * 50); + }); + } + } + + void _addDataPoint(double value) { + setState(() { + _spots.add(FlSpot(_index.toDouble(), value)); + if (_spots.length > widget.maxDataPoints) { + _spots.removeAt(0); + } + _index++; + }); + } + + @override + void dispose() { + _subscription?.cancel(); + _mockTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = widget.lineColor ?? theme.colorScheme.primary; + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + const Gap(4), + Text( + 'LIVE', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + const Gap(16), + Expanded( + child: _spots.length < 2 + ? const Center(child: CircularProgressIndicator()) + : LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 25, + getDrawingHorizontalLine: (value) => FlLine( + color: theme.colorScheme.outlineVariant + .withValues(alpha: 0.3), + strokeWidth: 1, + ), + ), + titlesData: const FlTitlesData( + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + ), + ), + ), + borderData: FlBorderData(show: false), + minY: 0, + maxY: 100, + lineBarsData: [ + LineChartBarData( + spots: _spots, + isCurved: true, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.3), + color.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + ), + duration: const Duration(milliseconds: 150), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/widgets/stats_card.dart b/lib/features/dashboard/presentation/widgets/stats_card.dart new file mode 100644 index 0000000..87bae01 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/stats_card.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class StatsCard extends StatelessWidget { + const StatsCard({ + required this.title, + required this.value, + this.icon, + this.iconColor, + this.change, + this.isPositive, + this.subtitle, + super.key, + }); + + final String title; + final String value; + final IconData? icon; + final Color? iconColor; + final String? change; + final bool? isPositive; + final String? subtitle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (icon != null) + Icon( + icon, + color: iconColor ?? theme.colorScheme.primary, + size: 24, + ), + ], + ), + const Gap(8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (change != null) ...[ + const Gap(8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: (isPositive ?? true) + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + change!, + style: theme.textTheme.bodySmall?.copyWith( + color: (isPositive ?? true) + ? Colors.green + : Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + if (subtitle != null) ...[ + const Gap(4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/user/presentation/screens/user_home_screen.dart b/lib/features/user/presentation/screens/user_home_screen.dart new file mode 100644 index 0000000..65b364d --- /dev/null +++ b/lib/features/user/presentation/screens/user_home_screen.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; + +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/providers/auth_provider.dart'; + +class UserHomeScreen extends ConsumerWidget { + const UserHomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authStateProvider); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 환영 메시지 + authState.when( + data: (user) => Text( + '안녕하세요, ${user?.name ?? '사용자'}님!', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), + const Gap(8), + Text( + '오늘의 요약을 확인하세요', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + const Gap(24), + + // 요약 카드 그리드 + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = context.isDesktop + ? 4 + : context.isTablet + ? 2 + : 1; + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 1.8, + children: const [ + _SummaryCard( + title: '진행중 작업', + value: '12', + icon: Icons.pending_actions, + color: Colors.blue, + ), + _SummaryCard( + title: '완료된 작업', + value: '48', + icon: Icons.check_circle_outline, + color: Colors.green, + ), + _SummaryCard( + title: '알림', + value: '3', + icon: Icons.notifications_outlined, + color: Colors.orange, + ), + _SummaryCard( + title: '메시지', + value: '7', + icon: Icons.mail_outlined, + color: Colors.purple, + ), + ], + ); + }, + ), + const Gap(24), + + // 최근 활동 + Text( + '최근 활동', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(16), + Card( + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 5, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) => ListTile( + leading: CircleAvatar( + backgroundColor: + context.colorScheme.primaryContainer, + child: Icon( + Icons.task_alt, + color: context.colorScheme.primary, + ), + ), + title: Text('활동 항목 ${index + 1}'), + subtitle: Text('${index + 1}시간 전'), + trailing: const Icon(Icons.chevron_right), + ), + ), + ), + ], + ), + ); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({ + required this.title, + required this.value, + required this.icon, + required this.color, + }); + + final String title; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Icon(icon, color: color, size: 24), + ], + ), + Text( + value, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/user/presentation/screens/user_profile_screen.dart b/lib/features/user/presentation/screens/user_profile_screen.dart new file mode 100644 index 0000000..50fd778 --- /dev/null +++ b/lib/features/user/presentation/screens/user_profile_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; + +import '../../../../core/utils/extensions.dart'; +import '../../../../shared/providers/auth_provider.dart'; + +class UserProfileScreen extends ConsumerWidget { + const UserProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authStateProvider); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '프로필', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(24), + + // 프로필 카드 + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Card( + child: Padding( + padding: const EdgeInsets.all(32), + child: authState.when( + data: (user) => Column( + children: [ + // 아바타 + CircleAvatar( + radius: 50, + backgroundColor: + context.colorScheme.primaryContainer, + child: Text( + (user?.name ?? 'U').substring(0, 1).toUpperCase(), + style: context.textTheme.headlineLarge?.copyWith( + color: context.colorScheme.primary, + ), + ), + ), + const Gap(16), + Text( + user?.name ?? '사용자', + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(4), + Text( + user?.email ?? '', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + const Gap(8), + Chip( + label: Text( + user?.role.value.toUpperCase() ?? 'USER', + ), + backgroundColor: + context.colorScheme.secondaryContainer, + ), + const Gap(32), + const Divider(), + const Gap(16), + + // 프로필 정보 목록 + _ProfileInfoTile( + icon: Icons.person_outlined, + label: '이름', + value: user?.name ?? '-', + ), + _ProfileInfoTile( + icon: Icons.email_outlined, + label: '이메일', + value: user?.email ?? '-', + ), + _ProfileInfoTile( + icon: Icons.shield_outlined, + label: '역할', + value: user?.role.value ?? '-', + ), + _ProfileInfoTile( + icon: Icons.calendar_today_outlined, + label: '가입일', + value: user?.createdAt?.toString().split(' ').first ?? + '-', + ), + const Gap(24), + + // 프로필 수정 버튼 + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + // TODO: 프로필 수정 기능 + }, + icon: const Icon(Icons.edit), + label: const Text('프로필 수정'), + ), + ), + ], + ), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, _) => Center( + child: Text('오류: $error'), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ProfileInfoTile extends StatelessWidget { + const _ProfileInfoTile({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const Gap(12), + SizedBox( + width: 80, + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb new file mode 100644 index 0000000..45b6254 --- /dev/null +++ b/lib/l10n/app_ko.arb @@ -0,0 +1,39 @@ +{ + "@@locale": "ko", + "appTitle": "Flutter Frame", + "@appTitle": { + "description": "앱 타이틀" + }, + "login": "로그인", + "register": "회원가입", + "logout": "로그아웃", + "email": "이메일", + "password": "비밀번호", + "passwordConfirm": "비밀번호 확인", + "name": "이름", + "home": "홈", + "profile": "프로필", + "dashboard": "대시보드", + "settings": "설정", + "userManagement": "사용자 관리", + "save": "저장", + "cancel": "취소", + "confirm": "확인", + "delete": "삭제", + "edit": "수정", + "add": "추가", + "search": "검색", + "loading": "로딩 중...", + "error": "오류가 발생했습니다", + "retry": "다시 시도", + "noData": "데이터가 없습니다", + "networkError": "네트워크 연결을 확인해주세요", + "unauthorized": "인증이 필요합니다", + "loginFailed": "로그인에 실패했습니다", + "registerSuccess": "회원가입이 완료되었습니다", + "logoutSuccess": "로그아웃되었습니다", + "deleteConfirm": "정말로 삭제하시겠습니까?", + "themeLight": "라이트 모드", + "themeDark": "다크 모드", + "themeSystem": "시스템 설정" +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..62c0b40 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:talker_riverpod_logger/talker_riverpod_logger.dart'; + +import 'app/app.dart'; +import 'core/logging/app_logger.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + // Talker 로거 생성 (ProviderScope 밖에서도 사용 가능하도록) + final container = ProviderContainer(); + final talker = container.read(appLoggerProvider); + + talker.info('App starting...'); + + runApp( + ProviderScope( + observers: [ + TalkerRiverpodObserver(talker: talker), + ], + child: const App(), + ), + ); +} diff --git a/lib/shared/models/api_response.dart b/lib/shared/models/api_response.dart new file mode 100644 index 0000000..1116f26 --- /dev/null +++ b/lib/shared/models/api_response.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'api_response.freezed.dart'; +part 'api_response.g.dart'; + +@Freezed(genericArgumentFactories: true) +class ApiResponse with _$ApiResponse { + const factory ApiResponse({ + required bool success, + required T data, + String? message, + @Default(200) int statusCode, + }) = _ApiResponse; + + factory ApiResponse.fromJson( + Map json, + T Function(Object?) fromJsonT, + ) => + _$ApiResponseFromJson(json, fromJsonT); +} diff --git a/lib/shared/models/pagination.dart b/lib/shared/models/pagination.dart new file mode 100644 index 0000000..d7c4e12 --- /dev/null +++ b/lib/shared/models/pagination.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'pagination.freezed.dart'; +part 'pagination.g.dart'; + +@Freezed(genericArgumentFactories: true) +class PaginatedResponse with _$PaginatedResponse { + const factory PaginatedResponse({ + required List items, + required int total, + required int page, + @JsonKey(name: 'page_size') required int pageSize, + @JsonKey(name: 'total_pages') required int totalPages, + @JsonKey(name: 'has_next') @Default(false) bool hasNext, + @JsonKey(name: 'has_prev') @Default(false) bool hasPrev, + }) = _PaginatedResponse; + + factory PaginatedResponse.fromJson( + Map json, + T Function(Object?) fromJsonT, + ) => + _$PaginatedResponseFromJson(json, fromJsonT); +} diff --git a/lib/shared/models/user_role.dart b/lib/shared/models/user_role.dart new file mode 100644 index 0000000..8837d2c --- /dev/null +++ b/lib/shared/models/user_role.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum(valueField: 'value') +enum UserRole { + admin('admin'), + user('user'); + + const UserRole(this.value); + + final String value; + + static UserRole fromString(String value) { + return UserRole.values.firstWhere( + (role) => role.value == value, + orElse: () => UserRole.user, + ); + } +} diff --git a/lib/shared/providers/auth_provider.dart b/lib/shared/providers/auth_provider.dart new file mode 100644 index 0000000..228ac8a --- /dev/null +++ b/lib/shared/providers/auth_provider.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../features/auth/domain/entities/user.dart'; +import '../../features/auth/presentation/providers/auth_providers.dart'; + +part 'auth_provider.g.dart'; + +@Riverpod(keepAlive: true) +class AuthState extends _$AuthState { + @override + FutureOr build() async { + final repository = ref.read(authRepositoryProvider); + final isLoggedIn = await repository.isLoggedIn(); + if (isLoggedIn) { + return repository.getCurrentUser(); + } + return null; + } + + void setUser(User user) { + state = AsyncData(user); + } + + void clearUser() { + state = const AsyncData(null); + } + + Future logout() async { + final repository = ref.read(authRepositoryProvider); + await repository.logout(); + state = const AsyncData(null); + } +} diff --git a/lib/shared/providers/connectivity_provider.dart b/lib/shared/providers/connectivity_provider.dart new file mode 100644 index 0000000..a9d4807 --- /dev/null +++ b/lib/shared/providers/connectivity_provider.dart @@ -0,0 +1,14 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'connectivity_provider.g.dart'; + +@riverpod +Stream connectivityStatus(Ref ref) { + return Connectivity().onConnectivityChanged.map( + (results) => results.any( + (result) => result != ConnectivityResult.none, + ), + ); +} diff --git a/lib/shared/widgets/app_scaffold.dart b/lib/shared/widgets/app_scaffold.dart new file mode 100644 index 0000000..bb8932e --- /dev/null +++ b/lib/shared/widgets/app_scaffold.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/app_providers.dart'; +import '../../core/router/route_names.dart'; +import '../../core/utils/extensions.dart'; +import '../providers/auth_provider.dart'; + +class AppScaffold extends ConsumerWidget { + const AppScaffold({ + required this.child, + required this.currentPath, + this.isAdmin = false, + super.key, + }); + + final Widget child; + final String currentPath; + final bool isAdmin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMobile = context.isMobile; + + return Scaffold( + appBar: AppBar( + title: Text(isAdmin ? '관리자' : 'Flutter Frame'), + actions: [ + IconButton( + icon: const Icon(Icons.brightness_6), + onPressed: () { + ref.read(themeModeNotifierProvider.notifier).toggleTheme(); + }, + tooltip: '테마 전환', + ), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) { + context.goNamed(RouteNames.login); + } + }, + tooltip: '로그아웃', + ), + ], + ), + drawer: isMobile ? _buildDrawer(context) : null, + body: Row( + children: [ + if (!isMobile) _buildSidebar(context), + Expanded(child: child), + ], + ), + ); + } + + Widget _buildSidebar(BuildContext context) { + return NavigationRail( + selectedIndex: _getSelectedIndex(), + onDestinationSelected: (index) => _onDestinationSelected(context, index), + labelType: NavigationRailLabelType.all, + leading: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Icon( + isAdmin ? Icons.admin_panel_settings : Icons.apps, + size: 32, + color: context.colorScheme.primary, + ), + ), + destinations: _getDestinations(), + ); + } + + Widget _buildDrawer(BuildContext context) { + return NavigationDrawer( + selectedIndex: _getSelectedIndex(), + onDestinationSelected: (index) { + Navigator.pop(context); + _onDestinationSelected(context, index); + }, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(28, 16, 16, 10), + child: Text( + isAdmin ? '관리자 메뉴' : '메뉴', + style: context.textTheme.titleSmall, + ), + ), + ..._getDrawerDestinations(), + ], + ); + } + + int _getSelectedIndex() { + if (isAdmin) { + if (currentPath.contains('/admin/home')) return 0; + if (currentPath.contains('/admin/users')) return 1; + if (currentPath.contains('/admin/dashboard')) return 2; + if (currentPath.contains('/admin/settings')) return 3; + } else { + if (currentPath.contains('/user/home')) return 0; + if (currentPath.contains('/user/profile')) return 1; + if (currentPath.contains('/user/dashboard')) return 2; + } + return 0; + } + + void _onDestinationSelected(BuildContext context, int index) { + if (isAdmin) { + switch (index) { + case 0: + context.goNamed(RouteNames.adminHome); + case 1: + context.goNamed(RouteNames.adminUsers); + case 2: + context.goNamed(RouteNames.adminDashboard); + case 3: + context.goNamed(RouteNames.adminSettings); + } + } else { + switch (index) { + case 0: + context.goNamed(RouteNames.userHome); + case 1: + context.goNamed(RouteNames.userProfile); + case 2: + context.goNamed(RouteNames.userDashboard); + } + } + } + + List _getDestinations() { + if (isAdmin) { + return const [ + NavigationRailDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('홈'), + ), + NavigationRailDestination( + icon: Icon(Icons.people_outlined), + selectedIcon: Icon(Icons.people), + label: Text('사용자 관리'), + ), + NavigationRailDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: Text('대시보드'), + ), + NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('설정'), + ), + ]; + } + return const [ + NavigationRailDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('홈'), + ), + NavigationRailDestination( + icon: Icon(Icons.person_outlined), + selectedIcon: Icon(Icons.person), + label: Text('프로필'), + ), + NavigationRailDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: Text('대시보드'), + ), + ]; + } + + List _getDrawerDestinations() { + if (isAdmin) { + return const [ + NavigationDrawerDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('홈'), + ), + NavigationDrawerDestination( + icon: Icon(Icons.people_outlined), + selectedIcon: Icon(Icons.people), + label: Text('사용자 관리'), + ), + NavigationDrawerDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: Text('대시보드'), + ), + NavigationDrawerDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('설정'), + ), + ]; + } + return const [ + NavigationDrawerDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('홈'), + ), + NavigationDrawerDestination( + icon: Icon(Icons.person_outlined), + selectedIcon: Icon(Icons.person), + label: Text('프로필'), + ), + NavigationDrawerDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: Text('대시보드'), + ), + ]; + } +} diff --git a/lib/shared/widgets/chart_widgets/bar_chart_widget.dart b/lib/shared/widgets/chart_widgets/bar_chart_widget.dart new file mode 100644 index 0000000..39ec0f1 --- /dev/null +++ b/lib/shared/widgets/chart_widgets/bar_chart_widget.dart @@ -0,0 +1,84 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class BarChartWidget extends StatelessWidget { + const BarChartWidget({ + required this.barGroups, + this.title, + this.maxY, + this.bottomTitles, + this.barColor, + super.key, + }); + + final List barGroups; + final String? title; + final double? maxY; + final Widget Function(double, TitleMeta)? bottomTitles; + final Color? barColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + title!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: BarChart( + BarChartData( + maxY: maxY, + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: bottomTitles ?? + (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + value.toInt().toString(), + style: theme.textTheme.bodySmall, + ), + ), + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + value.toInt().toString(), + style: theme.textTheme.bodySmall, + ), + ), + ), + ), + ), + barGroups: barGroups, + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/chart_widgets/line_chart_widget.dart b/lib/shared/widgets/chart_widgets/line_chart_widget.dart new file mode 100644 index 0000000..97187ea --- /dev/null +++ b/lib/shared/widgets/chart_widgets/line_chart_widget.dart @@ -0,0 +1,117 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class LineChartWidget extends StatelessWidget { + const LineChartWidget({ + required this.spots, + this.title, + this.lineColor, + this.gradientColors, + this.minY, + this.maxY, + this.showGrid = true, + this.bottomTitles, + this.leftTitles, + super.key, + }); + + final List spots; + final String? title; + final Color? lineColor; + final List? gradientColors; + final double? minY; + final double? maxY; + final bool showGrid; + final Widget Function(double, TitleMeta)? bottomTitles; + final Widget Function(double, TitleMeta)? leftTitles; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = lineColor ?? theme.colorScheme.primary; + final colors = gradientColors ?? + [ + primaryColor, + primaryColor.withValues(alpha: 0.3), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + title!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: LineChart( + LineChartData( + gridData: FlGridData(show: showGrid), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: bottomTitles ?? + (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + value.toInt().toString(), + style: theme.textTheme.bodySmall, + ), + ), + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: leftTitles ?? + (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + value.toInt().toString(), + style: theme.textTheme.bodySmall, + ), + ), + ), + ), + ), + borderData: FlBorderData(show: false), + minY: minY, + maxY: maxY, + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + color: primaryColor, + barWidth: 3, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: colors, + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/chart_widgets/pie_chart_widget.dart b/lib/shared/widgets/chart_widgets/pie_chart_widget.dart new file mode 100644 index 0000000..7d0825f --- /dev/null +++ b/lib/shared/widgets/chart_widgets/pie_chart_widget.dart @@ -0,0 +1,100 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class PieChartWidget extends StatelessWidget { + const PieChartWidget({ + required this.sections, + this.title, + this.centerText, + this.showLegend = true, + this.legendItems, + super.key, + }); + + final List sections; + final String? title; + final String? centerText; + final bool showLegend; + final List? legendItems; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + title!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Row( + children: [ + Expanded( + flex: 2, + child: PieChart( + PieChartData( + sections: sections, + centerSpaceRadius: 40, + sectionsSpace: 2, + ), + ), + ), + if (showLegend && legendItems != null) + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: legendItems! + .map( + (item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: item.color, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.label, + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ], + ); + } +} + +class LegendItem { + const LegendItem({ + required this.label, + required this.color, + }); + + final String label; + final Color color; +} diff --git a/lib/shared/widgets/confirm_dialog.dart b/lib/shared/widgets/confirm_dialog.dart new file mode 100644 index 0000000..424fec4 --- /dev/null +++ b/lib/shared/widgets/confirm_dialog.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class ConfirmDialog extends StatelessWidget { + const ConfirmDialog({ + required this.title, + required this.message, + this.confirmLabel = '확인', + this.cancelLabel = '취소', + this.isDestructive = false, + super.key, + }); + + final String title; + final String message; + final String confirmLabel; + final String cancelLabel; + final bool isDestructive; + + static Future show( + BuildContext context, { + required String title, + required String message, + String confirmLabel = '확인', + String cancelLabel = '취소', + bool isDestructive = false, + }) { + return showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + isDestructive: isDestructive, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + const Gap(8), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: isDestructive + ? FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ) + : null, + child: Text(confirmLabel), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/data_table_widget.dart b/lib/shared/widgets/data_table_widget.dart new file mode 100644 index 0000000..cca3ac9 --- /dev/null +++ b/lib/shared/widgets/data_table_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:pluto_grid/pluto_grid.dart'; + +class DataTableWidget extends StatelessWidget { + const DataTableWidget({ + required this.columns, + required this.rows, + this.onRowDoubleTap, + this.onLoaded, + this.configuration, + super.key, + }); + + final List columns; + final List rows; + final void Function(PlutoGridOnRowDoubleTapEvent)? onRowDoubleTap; + final void Function(PlutoGridOnLoadedEvent)? onLoaded; + final PlutoGridConfiguration? configuration; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return PlutoGrid( + columns: columns, + rows: rows, + onRowDoubleTap: onRowDoubleTap, + onLoaded: onLoaded, + configuration: configuration ?? + PlutoGridConfiguration( + style: PlutoGridStyleConfig( + gridBackgroundColor: theme.colorScheme.surface, + rowColor: theme.colorScheme.surface, + activatedColor: theme.colorScheme.primaryContainer, + activatedBorderColor: theme.colorScheme.primary, + gridBorderColor: theme.colorScheme.outlineVariant, + borderColor: theme.colorScheme.outlineVariant, + cellTextStyle: theme.textTheme.bodyMedium!, + columnTextStyle: theme.textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + columnSize: const PlutoGridColumnSizeConfig( + autoSizeMode: PlutoAutoSizeMode.scale, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/empty_state_widget.dart b/lib/shared/widgets/empty_state_widget.dart new file mode 100644 index 0000000..9e8105f --- /dev/null +++ b/lib/shared/widgets/empty_state_widget.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class EmptyStateWidget extends StatelessWidget { + const EmptyStateWidget({ + required this.message, + this.icon, + this.actionLabel, + this.onAction, + super.key, + }); + + final String message; + final IconData? icon; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon ?? Icons.inbox_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const Gap(16), + Text( + message, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (actionLabel != null && onAction != null) ...[ + const Gap(16), + FilledButton.tonal( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/error_widget.dart b/lib/shared/widgets/error_widget.dart new file mode 100644 index 0000000..ae8f8df --- /dev/null +++ b/lib/shared/widgets/error_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class AppErrorWidget extends StatelessWidget { + const AppErrorWidget({ + required this.message, + this.onRetry, + this.icon, + super.key, + }); + + final String message; + final VoidCallback? onRetry; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon ?? Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const Gap(16), + Text( + message, + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const Gap(16), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('다시 시도'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/loading_widget.dart b/lib/shared/widgets/loading_widget.dart new file mode 100644 index 0000000..7b2cded --- /dev/null +++ b/lib/shared/widgets/loading_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; + +class LoadingWidget extends StatelessWidget { + const LoadingWidget({ + this.size = 50, + this.color, + this.message, + super.key, + }); + + final double size; + final Color? color; + final String? message; + + @override + Widget build(BuildContext context) { + final effectiveColor = color ?? Theme.of(context).colorScheme.primary; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LoadingAnimationWidget.staggeredDotsWave( + color: effectiveColor, + size: size, + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9fd751a --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,81 @@ +name: flutter_frame +description: "Cross-platform Flutter app framework with Clean Architecture" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.8.0 + +dependencies: + flutter: + sdk: flutter + + # 상태관리 & 라우팅 + flutter_riverpod: ^3.2.1 + riverpod_annotation: ^2.6.1 + go_router: ^17.1.0 + + # 네트워크 + dio: ^5.9.1 + retrofit: ^4.9.2 + web_socket_channel: ^3.0.2 + + # 데이터 모델 + freezed_annotation: ^3.0.0 + json_annotation: ^4.9.0 + + # UI & 디자인 + shadcn_ui: ^0.45.2 + flex_color_scheme: ^8.4.0 + fl_chart: ^1.1.1 + pluto_grid: ^8.1.0 + responsive_framework: ^1.5.1 + gap: ^3.0.1 + cached_network_image: ^3.4.1 + flutter_svg: ^2.0.17 + loading_animation_widget: ^1.3.0 + shimmer: ^3.0.0 + + # 폼 & 유효성 검사 + reactive_forms: ^18.2.2 + + # 로컬 저장소 & 보안 + flutter_secure_storage: ^9.2.4 + shared_preferences: ^2.3.5 + + # 로깅 + talker: ^5.1.13 + talker_flutter: ^5.1.13 + talker_dio_logger: ^5.1.13 + talker_riverpod_logger: ^5.1.13 + + # 유틸리티 + intl: ^0.19.0 + envied: ^1.1.1 + package_info_plus: ^8.3.0 + url_launcher: ^6.3.1 + connectivity_plus: ^6.1.4 + +dev_dependencies: + flutter_test: + sdk: flutter + + # 코드 생성 + riverpod_generator: ^2.6.5 + retrofit_generator: ^9.1.13 + freezed: ^3.2.5 + json_serializable: ^6.9.5 + build_runner: ^2.4.15 + envied_generator: ^1.1.1 + + # 린트 & 테스트 + very_good_analysis: ^7.0.0 + mocktail: ^1.0.4 + +flutter: + uses-material-design: true + + assets: [] + # - assets/images/ + # - assets/icons/ + # - assets/fonts/