초기 커밋
This commit is contained in:
36
lib/app/app.dart
Normal file
36
lib/app/app.dart
Normal file
@@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/app/app_providers.dart
Normal file
24
lib/app/app_providers.dart
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
lib/app/theme/app_colors.dart
Normal file
35
lib/app/theme/app_colors.dart
Normal file
@@ -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);
|
||||
}
|
||||
62
lib/app/theme/app_theme.dart
Normal file
62
lib/app/theme/app_theme.dart
Normal file
@@ -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',
|
||||
);
|
||||
}
|
||||
27
lib/core/constants/api_constants.dart
Normal file
27
lib/core/constants/api_constants.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
abstract final class ApiConstants {
|
||||
static const String baseUrl = 'http://localhost:8000/api/v1';
|
||||
static const String wsUrl = 'ws://localhost:8000/ws';
|
||||
|
||||
// Auth
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
static const String logout = '/auth/logout';
|
||||
|
||||
// Users
|
||||
static const String users = '/users';
|
||||
static const String userProfile = '/users/me';
|
||||
|
||||
// Dashboard
|
||||
static const String dashboardStats = '/dashboard/stats';
|
||||
static const String dashboardMetrics = '/dashboard/metrics';
|
||||
|
||||
// Admin
|
||||
static const String adminUsers = '/admin/users';
|
||||
static const String adminSettings = '/admin/settings';
|
||||
|
||||
// Timeouts
|
||||
static const Duration connectTimeout = Duration(seconds: 15);
|
||||
static const Duration receiveTimeout = Duration(seconds: 15);
|
||||
static const Duration sendTimeout = Duration(seconds: 15);
|
||||
}
|
||||
22
lib/core/constants/app_constants.dart
Normal file
22
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
abstract final class AppConstants {
|
||||
static const String appName = 'Flutter Frame';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// Storage Keys
|
||||
static const String accessTokenKey = 'access_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
static const String userKey = 'user_data';
|
||||
static const String themeKey = 'theme_mode';
|
||||
static const String localeKey = 'locale';
|
||||
|
||||
// Pagination
|
||||
static const int defaultPageSize = 20;
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
// Debounce
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
// Animation
|
||||
static const Duration animationDuration = Duration(milliseconds: 300);
|
||||
static const Duration shortAnimationDuration = Duration(milliseconds: 150);
|
||||
}
|
||||
15
lib/core/env/env.dart
vendored
Normal file
15
lib/core/env/env.dart
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:envied/envied.dart';
|
||||
|
||||
part 'env.g.dart';
|
||||
|
||||
@Envied(path: '.env', obfuscate: true)
|
||||
abstract class Env {
|
||||
@EnviedField(varName: 'API_BASE_URL', defaultValue: 'http://localhost:8000/api/v1')
|
||||
static String apiBaseUrl = _Env.apiBaseUrl;
|
||||
|
||||
@EnviedField(varName: 'WS_BASE_URL', defaultValue: 'ws://localhost:8000/ws')
|
||||
static String wsBaseUrl = _Env.wsBaseUrl;
|
||||
|
||||
@EnviedField(varName: 'ENV', defaultValue: 'development')
|
||||
static String environment = _Env.environment;
|
||||
}
|
||||
39
lib/core/error/exceptions.dart
Normal file
39
lib/core/error/exceptions.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class ServerException implements Exception {
|
||||
const ServerException({
|
||||
required this.message,
|
||||
required this.statusCode,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int statusCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException(message: $message, statusCode: $statusCode)';
|
||||
}
|
||||
|
||||
class CacheException implements Exception {
|
||||
const CacheException({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'CacheException(message: $message)';
|
||||
}
|
||||
|
||||
class NetworkException implements Exception {
|
||||
const NetworkException({this.message = 'No internet connection'});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException(message: $message)';
|
||||
}
|
||||
|
||||
class UnauthorizedException implements Exception {
|
||||
const UnauthorizedException({this.message = 'Unauthorized'});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedException(message: $message)';
|
||||
}
|
||||
27
lib/core/error/failures.dart
Normal file
27
lib/core/error/failures.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'failures.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class Failure with _$Failure {
|
||||
const factory Failure.server({
|
||||
required String message,
|
||||
@Default(500) int statusCode,
|
||||
}) = ServerFailure;
|
||||
|
||||
const factory Failure.cache({
|
||||
required String message,
|
||||
}) = CacheFailure;
|
||||
|
||||
const factory Failure.network({
|
||||
@Default('네트워크 연결을 확인해주세요') String message,
|
||||
}) = NetworkFailure;
|
||||
|
||||
const factory Failure.unauthorized({
|
||||
@Default('인증이 필요합니다') String message,
|
||||
}) = UnauthorizedFailure;
|
||||
|
||||
const factory Failure.unknown({
|
||||
@Default('알 수 없는 오류가 발생했습니다') String message,
|
||||
}) = UnknownFailure;
|
||||
}
|
||||
22
lib/core/logging/app_logger.dart
Normal file
22
lib/core/logging/app_logger.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:talker/talker.dart';
|
||||
|
||||
part 'app_logger.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Talker appLogger(Ref ref) {
|
||||
return Talker(
|
||||
settings: TalkerSettings(
|
||||
useHistory: true,
|
||||
useConsoleLogs: true,
|
||||
maxHistoryItems: 1000,
|
||||
),
|
||||
logger: TalkerLogger(
|
||||
settings: TalkerLoggerSettings(
|
||||
level: LogLevel.verbose,
|
||||
enableColors: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
26
lib/core/network/auth_interceptor.dart
Normal file
26
lib/core/network/auth_interceptor.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor({required this.ref});
|
||||
|
||||
final Ref ref;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
|
||||
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
44
lib/core/network/dio_client.dart
Normal file
44
lib/core/network/dio_client.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger.dart';
|
||||
|
||||
import '../constants/api_constants.dart';
|
||||
import '../logging/app_logger.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
import 'token_refresh_interceptor.dart';
|
||||
|
||||
part 'dio_client.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dio(Ref ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ApiConstants.baseUrl,
|
||||
connectTimeout: ApiConstants.connectTimeout,
|
||||
receiveTimeout: ApiConstants.receiveTimeout,
|
||||
sendTimeout: ApiConstants.sendTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final talker = ref.read(appLoggerProvider);
|
||||
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(ref: ref),
|
||||
TokenRefreshInterceptor(ref: ref, dio: dio),
|
||||
TalkerDioLogger(
|
||||
talker: talker,
|
||||
settings: const TalkerDioLoggerSettings(
|
||||
printRequestHeaders: true,
|
||||
printResponseHeaders: false,
|
||||
printResponseData: true,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return dio;
|
||||
}
|
||||
109
lib/core/network/socket_manager.dart
Normal file
109
lib/core/network/socket_manager.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:talker/talker.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../constants/api_constants.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../logging/app_logger.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
part 'socket_manager.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SocketManager socketManager(Ref ref) {
|
||||
final talker = ref.read(appLoggerProvider);
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
final manager = SocketManager(talker: talker, secureStorage: secureStorage);
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
}
|
||||
|
||||
class SocketManager {
|
||||
SocketManager({
|
||||
required this.talker,
|
||||
required this.secureStorage,
|
||||
});
|
||||
|
||||
final Talker talker;
|
||||
final SecureStorage secureStorage;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _reconnectTimer;
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
bool _isConnected = false;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
Future<void> connect({String? path}) async {
|
||||
try {
|
||||
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
|
||||
final wsUrl = '${ApiConstants.wsUrl}${path ?? ''}';
|
||||
final uri = Uri.parse(
|
||||
token != null ? '$wsUrl?token=$token' : wsUrl,
|
||||
);
|
||||
|
||||
_channel = WebSocketChannel.connect(uri);
|
||||
await _channel!.ready;
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
talker.info('WebSocket connected: $wsUrl');
|
||||
|
||||
_channel!.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
final decoded = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
_messageController.add(decoded);
|
||||
} catch (e) {
|
||||
talker.error('WebSocket message parse error', e);
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
talker.error('WebSocket error', error);
|
||||
_handleDisconnect();
|
||||
},
|
||||
onDone: () {
|
||||
talker.warning('WebSocket disconnected');
|
||||
_handleDisconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
talker.error('WebSocket connection failed', e);
|
||||
_handleDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDisconnect() {
|
||||
_isConnected = false;
|
||||
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||||
final delay = Duration(seconds: _reconnectAttempts * 2 + 1);
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
_reconnectAttempts++;
|
||||
talker.info('WebSocket reconnect attempt $_reconnectAttempts');
|
||||
connect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void send(Map<String, dynamic> data) {
|
||||
if (_isConnected && _channel != null) {
|
||||
_channel!.sink.add(jsonEncode(data));
|
||||
} else {
|
||||
talker.warning('WebSocket not connected, message not sent');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_reconnectTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_messageController.close();
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
77
lib/core/network/token_refresh_interceptor.dart
Normal file
77
lib/core/network/token_refresh_interceptor.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../constants/api_constants.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
class TokenRefreshInterceptor extends Interceptor {
|
||||
TokenRefreshInterceptor({
|
||||
required this.ref,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
final Ref ref;
|
||||
final Dio dio;
|
||||
bool _isRefreshing = false;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401 && !_isRefreshing) {
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
final refreshToken = await secureStorage.read(
|
||||
key: AppConstants.refreshTokenKey,
|
||||
);
|
||||
|
||||
if (refreshToken == null) {
|
||||
_isRefreshing = false;
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// Request new tokens
|
||||
final refreshDio = Dio(
|
||||
BaseOptions(baseUrl: ApiConstants.baseUrl),
|
||||
);
|
||||
final response = await refreshDio.post(
|
||||
ApiConstants.refreshToken,
|
||||
data: {'refresh_token': refreshToken},
|
||||
);
|
||||
|
||||
final newAccessToken = response.data['access_token'] as String;
|
||||
final newRefreshToken = response.data['refresh_token'] as String;
|
||||
|
||||
// Save new tokens
|
||||
await secureStorage.write(
|
||||
key: AppConstants.accessTokenKey,
|
||||
value: newAccessToken,
|
||||
);
|
||||
await secureStorage.write(
|
||||
key: AppConstants.refreshTokenKey,
|
||||
value: newRefreshToken,
|
||||
);
|
||||
|
||||
// Retry original request
|
||||
final options = err.requestOptions;
|
||||
options.headers['Authorization'] = 'Bearer $newAccessToken';
|
||||
|
||||
final retryResponse = await dio.fetch(options);
|
||||
_isRefreshing = false;
|
||||
return handler.resolve(retryResponse);
|
||||
} on DioException {
|
||||
_isRefreshing = false;
|
||||
// Token refresh failed, clear tokens
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
await secureStorage.delete(key: AppConstants.accessTokenKey);
|
||||
await secureStorage.delete(key: AppConstants.refreshTokenKey);
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
114
lib/core/router/app_router.dart
Normal file
114
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../features/admin/presentation/screens/admin_home_screen.dart';
|
||||
import '../../features/admin/presentation/screens/system_settings_screen.dart';
|
||||
import '../../features/admin/presentation/screens/user_management_screen.dart';
|
||||
import '../../features/auth/presentation/screens/login_screen.dart';
|
||||
import '../../features/auth/presentation/screens/register_screen.dart';
|
||||
import '../../features/dashboard/presentation/screens/dashboard_screen.dart';
|
||||
import '../../features/user/presentation/screens/user_home_screen.dart';
|
||||
import '../../features/user/presentation/screens/user_profile_screen.dart';
|
||||
import '../../shared/providers/auth_provider.dart';
|
||||
import '../../shared/widgets/app_scaffold.dart';
|
||||
import 'auth_guard.dart';
|
||||
import 'route_names.dart';
|
||||
|
||||
part 'app_router.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GoRouter appRouter(Ref ref) {
|
||||
final authGuard = AuthGuard(ref);
|
||||
final authState = ref.watch(authStateProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: RoutePaths.login,
|
||||
debugLogDiagnostics: true,
|
||||
refreshListenable: _GoRouterRefreshStream(ref, authState),
|
||||
redirect: (context, state) {
|
||||
final location = state.uri.toString();
|
||||
return authGuard.redirect(context, location);
|
||||
},
|
||||
routes: [
|
||||
// Auth Routes (비인증)
|
||||
GoRoute(
|
||||
name: RouteNames.login,
|
||||
path: RoutePaths.login,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.register,
|
||||
path: RoutePaths.register,
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
|
||||
// User Shell Route
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(
|
||||
currentPath: state.uri.toString(),
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: RouteNames.userHome,
|
||||
path: RoutePaths.userHome,
|
||||
builder: (context, state) => const UserHomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.userProfile,
|
||||
path: RoutePaths.userProfile,
|
||||
builder: (context, state) => const UserProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.userDashboard,
|
||||
path: RoutePaths.userDashboard,
|
||||
builder: (context, state) => const DashboardScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Admin Shell Route
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(
|
||||
currentPath: state.uri.toString(),
|
||||
isAdmin: true,
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: RouteNames.adminHome,
|
||||
path: RoutePaths.adminHome,
|
||||
builder: (context, state) => const AdminHomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.adminUsers,
|
||||
path: RoutePaths.adminUsers,
|
||||
builder: (context, state) => const UserManagementScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.adminDashboard,
|
||||
path: RoutePaths.adminDashboard,
|
||||
builder: (context, state) => const DashboardScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: RouteNames.adminSettings,
|
||||
path: RoutePaths.adminSettings,
|
||||
builder: (context, state) => const SystemSettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// GoRouter에서 Riverpod 상태 변경 시 리프레시하기 위한 Listenable 래퍼
|
||||
class _GoRouterRefreshStream extends ChangeNotifier {
|
||||
_GoRouterRefreshStream(this.ref, dynamic _) {
|
||||
// authState 변경 시 GoRouter refresh 트리거
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final Ref ref;
|
||||
}
|
||||
64
lib/core/router/auth_guard.dart
Normal file
64
lib/core/router/auth_guard.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../shared/models/user_role.dart';
|
||||
import '../../shared/providers/auth_provider.dart';
|
||||
import 'route_names.dart';
|
||||
|
||||
/// 인증/역할 기반 라우트 가드
|
||||
class AuthGuard {
|
||||
const AuthGuard(this.ref);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
/// GoRouter redirect 콜백
|
||||
String? redirect(BuildContext context, String location) {
|
||||
final authState = ref.read(authStateProvider);
|
||||
|
||||
return authState.when(
|
||||
data: (user) {
|
||||
final isLoggedIn = user != null;
|
||||
final isAuthRoute =
|
||||
location.startsWith(RoutePaths.login) ||
|
||||
location.startsWith(RoutePaths.register);
|
||||
|
||||
// 비로그인 사용자가 인증 페이지 외 접근 시 → 로그인으로
|
||||
if (!isLoggedIn && !isAuthRoute) {
|
||||
return RoutePaths.login;
|
||||
}
|
||||
|
||||
// 로그인 사용자가 인증 페이지 접근 시 → 역할에 따라 리다이렉트
|
||||
if (isLoggedIn && isAuthRoute) {
|
||||
return _redirectByRole(user!.role);
|
||||
}
|
||||
|
||||
// 역할 기반 접근 제어
|
||||
if (isLoggedIn) {
|
||||
return _checkRoleAccess(location, user!.role);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
loading: () => null,
|
||||
error: (_, __) => RoutePaths.login,
|
||||
);
|
||||
}
|
||||
|
||||
/// 역할에 따른 기본 페이지 리다이렉트
|
||||
String _redirectByRole(UserRole role) {
|
||||
return switch (role) {
|
||||
UserRole.admin => RoutePaths.adminHome,
|
||||
UserRole.user => RoutePaths.userHome,
|
||||
};
|
||||
}
|
||||
|
||||
/// 역할 기반 접근 제어
|
||||
String? _checkRoleAccess(String location, UserRole role) {
|
||||
// 일반 사용자가 관리자 페이지 접근 시도
|
||||
if (location.startsWith(RoutePaths.admin) && role != UserRole.admin) {
|
||||
return RoutePaths.userHome;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
lib/core/router/route_names.dart
Normal file
39
lib/core/router/route_names.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
/// 라우트 이름 상수
|
||||
abstract final class RouteNames {
|
||||
// Auth
|
||||
static const String login = 'login';
|
||||
static const String register = 'register';
|
||||
|
||||
// User
|
||||
static const String userShell = 'user-shell';
|
||||
static const String userHome = 'user-home';
|
||||
static const String userProfile = 'user-profile';
|
||||
static const String userDashboard = 'user-dashboard';
|
||||
|
||||
// Admin
|
||||
static const String adminShell = 'admin-shell';
|
||||
static const String adminHome = 'admin-home';
|
||||
static const String adminUsers = 'admin-users';
|
||||
static const String adminDashboard = 'admin-dashboard';
|
||||
static const String adminSettings = 'admin-settings';
|
||||
}
|
||||
|
||||
/// 라우트 경로 상수
|
||||
abstract final class RoutePaths {
|
||||
// Auth
|
||||
static const String login = '/login';
|
||||
static const String register = '/register';
|
||||
|
||||
// User
|
||||
static const String user = '/user';
|
||||
static const String userHome = '/user/home';
|
||||
static const String userProfile = '/user/profile';
|
||||
static const String userDashboard = '/user/dashboard';
|
||||
|
||||
// Admin
|
||||
static const String admin = '/admin';
|
||||
static const String adminHome = '/admin/home';
|
||||
static const String adminUsers = '/admin/users';
|
||||
static const String adminDashboard = '/admin/dashboard';
|
||||
static const String adminSettings = '/admin/settings';
|
||||
}
|
||||
55
lib/core/storage/local_storage.dart
Normal file
55
lib/core/storage/local_storage.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'local_storage.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
LocalStorage localStorage(Ref ref) {
|
||||
return LocalStorage();
|
||||
}
|
||||
|
||||
class LocalStorage {
|
||||
Future<SharedPreferences> get _prefs async =>
|
||||
SharedPreferences.getInstance();
|
||||
|
||||
Future<void> setString(String key, String value) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
|
||||
Future<String?> getString(String key) async {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
Future<void> setBool(String key, {required bool value}) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setBool(key, value);
|
||||
}
|
||||
|
||||
Future<bool?> getBool(String key) async {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getBool(key);
|
||||
}
|
||||
|
||||
Future<void> setInt(String key, int value) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setInt(key, value);
|
||||
}
|
||||
|
||||
Future<int?> getInt(String key) async {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getInt(key);
|
||||
}
|
||||
|
||||
Future<void> remove(String key) async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.clear();
|
||||
}
|
||||
}
|
||||
36
lib/core/storage/secure_storage.dart
Normal file
36
lib/core/storage/secure_storage.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'secure_storage.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SecureStorage secureStorage(Ref ref) {
|
||||
return SecureStorage();
|
||||
}
|
||||
|
||||
class SecureStorage {
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
Future<void> write({required String key, required String value}) async {
|
||||
await _storage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
Future<String?> read({required String key}) async {
|
||||
return _storage.read(key: key);
|
||||
}
|
||||
|
||||
Future<void> delete({required String key}) async {
|
||||
await _storage.delete(key: key);
|
||||
}
|
||||
|
||||
Future<void> deleteAll() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
Future<bool> containsKey({required String key}) async {
|
||||
return _storage.containsKey(key: key);
|
||||
}
|
||||
}
|
||||
44
lib/core/utils/date_utils.dart
Normal file
44
lib/core/utils/date_utils.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
abstract final class AppDateUtils {
|
||||
static final _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
static final _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
static final _timeFormat = DateFormat('HH:mm');
|
||||
static final _koreanDateFormat = DateFormat('yyyy년 MM월 dd일');
|
||||
|
||||
static String formatDate(DateTime date) => _dateFormat.format(date);
|
||||
|
||||
static String formatDateTime(DateTime date) => _dateTimeFormat.format(date);
|
||||
|
||||
static String formatTime(DateTime date) => _timeFormat.format(date);
|
||||
|
||||
static String formatKoreanDate(DateTime date) => _koreanDateFormat.format(date);
|
||||
|
||||
static DateTime? tryParse(String? dateString) {
|
||||
if (dateString == null) return null;
|
||||
try {
|
||||
return DateTime.parse(dateString);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String timeAgo(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays > 365) {
|
||||
return '${diff.inDays ~/ 365}년 전';
|
||||
} else if (diff.inDays > 30) {
|
||||
return '${diff.inDays ~/ 30}개월 전';
|
||||
} else if (diff.inDays > 0) {
|
||||
return '${diff.inDays}일 전';
|
||||
} else if (diff.inHours > 0) {
|
||||
return '${diff.inHours}시간 전';
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return '${diff.inMinutes}분 전';
|
||||
} else {
|
||||
return '방금 전';
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/core/utils/extensions.dart
Normal file
42
lib/core/utils/extensions.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension BuildContextExtension on BuildContext {
|
||||
ThemeData get theme => Theme.of(this);
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
MediaQueryData get mediaQuery => MediaQuery.of(this);
|
||||
Size get screenSize => MediaQuery.sizeOf(this);
|
||||
double get screenWidth => screenSize.width;
|
||||
double get screenHeight => screenSize.height;
|
||||
bool get isMobile => screenWidth < 600;
|
||||
bool get isTablet => screenWidth >= 600 && screenWidth < 1200;
|
||||
bool get isDesktop => screenWidth >= 1200;
|
||||
|
||||
void showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? colorScheme.error : null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
String get capitalize =>
|
||||
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
|
||||
|
||||
bool get isValidEmail => RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
).hasMatch(this);
|
||||
|
||||
bool get isValidPassword => length >= 8;
|
||||
}
|
||||
|
||||
extension DateTimeExtension on DateTime {
|
||||
String get toFormattedString => '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||
|
||||
String get toFormattedDateTime =>
|
||||
'$toFormattedString ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
45
lib/core/utils/validators.dart
Normal file
45
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
abstract final class Validators {
|
||||
static String? email(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '이메일을 입력해주세요';
|
||||
}
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
return '올바른 이메일 형식이 아닙니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? password(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '비밀번호를 입력해주세요';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return '비밀번호는 8자 이상이어야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? required(String? value, [String? fieldName]) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '${fieldName ?? '이 필드'}를 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? minLength(String? value, int min, [String? fieldName]) {
|
||||
if (value == null || value.length < min) {
|
||||
return '${fieldName ?? '이 필드'}는 $min자 이상이어야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? maxLength(String? value, int max, [String? fieldName]) {
|
||||
if (value != null && value.length > max) {
|
||||
return '${fieldName ?? '이 필드'}는 $max자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
238
lib/features/admin/presentation/screens/admin_home_screen.dart
Normal file
238
lib/features/admin/presentation/screens/admin_home_screen.dart
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ThemeMode>(
|
||||
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<Widget> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<UserManagementScreen> createState() =>
|
||||
_UserManagementScreenState();
|
||||
}
|
||||
|
||||
class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
late PlutoGridStateManager stateManager;
|
||||
|
||||
// Mock 데이터
|
||||
final List<Map<String, dynamic>> _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<PlutoColumn> 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<PlutoRow> 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<void>(
|
||||
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<void> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/features/auth/data/datasources/auth_remote_source.dart
Normal file
28
lib/features/auth/data/datasources/auth_remote_source.dart
Normal file
@@ -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<TokenResponse> login(@Body() LoginRequest request);
|
||||
|
||||
@POST(ApiConstants.register)
|
||||
Future<TokenResponse> register(@Body() RegisterRequest request);
|
||||
|
||||
@POST(ApiConstants.logout)
|
||||
Future<void> logout();
|
||||
|
||||
@POST(ApiConstants.refreshToken)
|
||||
Future<TokenResponse> refreshToken(
|
||||
@Body() Map<String, String> body,
|
||||
);
|
||||
}
|
||||
15
lib/features/auth/data/models/login_request.dart
Normal file
15
lib/features/auth/data/models/login_request.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||
_$LoginRequestFromJson(json);
|
||||
}
|
||||
16
lib/features/auth/data/models/register_request.dart
Normal file
16
lib/features/auth/data/models/register_request.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||
_$RegisterRequestFromJson(json);
|
||||
}
|
||||
16
lib/features/auth/data/models/token_response.dart
Normal file
16
lib/features/auth/data/models/token_response.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||
_$TokenResponseFromJson(json);
|
||||
}
|
||||
123
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
123
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
@@ -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<User> 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<User> 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<void> 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<User?> getCurrentUser() async {
|
||||
try {
|
||||
final userData = await secureStorage.read(key: AppConstants.userKey);
|
||||
if (userData != null) {
|
||||
return User.fromJson(
|
||||
jsonDecode(userData) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
talker.error('Get current user failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isLoggedIn() async {
|
||||
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
|
||||
return token != null;
|
||||
}
|
||||
|
||||
Future<void> _saveTokens(String accessToken, String refreshToken) async {
|
||||
await secureStorage.write(
|
||||
key: AppConstants.accessTokenKey,
|
||||
value: accessToken,
|
||||
);
|
||||
await secureStorage.write(
|
||||
key: AppConstants.refreshTokenKey,
|
||||
value: refreshToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/features/auth/domain/entities/user.dart
Normal file
22
lib/features/auth/domain/entities/user.dart
Normal file
@@ -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<String, dynamic> json) => _$UserFromJson(json);
|
||||
}
|
||||
20
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
20
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import '../entities/user.dart';
|
||||
|
||||
abstract class AuthRepository {
|
||||
Future<User> login({
|
||||
required String email,
|
||||
required String password,
|
||||
});
|
||||
|
||||
Future<User> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String name,
|
||||
});
|
||||
|
||||
Future<void> logout();
|
||||
|
||||
Future<User?> getCurrentUser();
|
||||
|
||||
Future<bool> isLoggedIn();
|
||||
}
|
||||
15
lib/features/auth/domain/usecases/login_usecase.dart
Normal file
15
lib/features/auth/domain/usecases/login_usecase.dart
Normal file
@@ -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<User> call({
|
||||
required String email,
|
||||
required String password,
|
||||
}) {
|
||||
return repository.login(email: email, password: password);
|
||||
}
|
||||
}
|
||||
9
lib/features/auth/domain/usecases/logout_usecase.dart
Normal file
9
lib/features/auth/domain/usecases/logout_usecase.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import '../../domain/repositories/auth_repository.dart';
|
||||
|
||||
class LogoutUseCase {
|
||||
LogoutUseCase({required this.repository});
|
||||
|
||||
final AuthRepository repository;
|
||||
|
||||
Future<void> call() => repository.logout();
|
||||
}
|
||||
20
lib/features/auth/domain/usecases/register_usecase.dart
Normal file
20
lib/features/auth/domain/usecases/register_usecase.dart
Normal file
@@ -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<User> call({
|
||||
required String email,
|
||||
required String password,
|
||||
required String name,
|
||||
}) {
|
||||
return repository.register(
|
||||
email: email,
|
||||
password: password,
|
||||
name: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/features/auth/presentation/providers/auth_providers.dart
Normal file
78
lib/features/auth/presentation/providers/auth_providers.dart
Normal file
@@ -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<void> build() {}
|
||||
|
||||
Future<User?> 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<void> build() {}
|
||||
|
||||
Future<User?> 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;
|
||||
}
|
||||
}
|
||||
27
lib/features/auth/presentation/screens/login_screen.dart
Normal file
27
lib/features/auth/presentation/screens/login_screen.dart
Normal file
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/features/auth/presentation/screens/register_screen.dart
Normal file
27
lib/features/auth/presentation/screens/register_screen.dart
Normal file
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
148
lib/features/auth/presentation/widgets/login_form.dart
Normal file
148
lib/features/auth/presentation/widgets/login_form.dart
Normal file
@@ -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<LoginForm> createState() => _LoginFormState();
|
||||
}
|
||||
|
||||
class _LoginFormState extends ConsumerState<LoginForm> {
|
||||
late final FormGroup form;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
form = FormGroup({
|
||||
'email': FormControl<String>(
|
||||
validators: [Validators.required, Validators.email],
|
||||
),
|
||||
'password': FormControl<String>(
|
||||
validators: [Validators.required, Validators.minLength(8)],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
form.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
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<String>(
|
||||
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('회원가입'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
lib/features/auth/presentation/widgets/register_form.dart
Normal file
184
lib/features/auth/presentation/widgets/register_form.dart
Normal file
@@ -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<RegisterForm> createState() => _RegisterFormState();
|
||||
}
|
||||
|
||||
class _RegisterFormState extends ConsumerState<RegisterForm> {
|
||||
late final FormGroup form;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
form = FormGroup({
|
||||
'name': FormControl<String>(
|
||||
validators: [Validators.required],
|
||||
),
|
||||
'email': FormControl<String>(
|
||||
validators: [Validators.required, Validators.email],
|
||||
),
|
||||
'password': FormControl<String>(
|
||||
validators: [Validators.required, Validators.minLength(8)],
|
||||
),
|
||||
'confirmPassword': FormControl<String>(
|
||||
validators: [Validators.required],
|
||||
),
|
||||
}, validators: [
|
||||
Validators.mustMatch('password', 'confirmPassword'),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
form.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
formControlName: 'name',
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이름',
|
||||
hintText: '홍길동',
|
||||
prefixIcon: Icon(Icons.person_outlined),
|
||||
),
|
||||
validationMessages: {
|
||||
'required': (_) => '이름을 입력해주세요',
|
||||
},
|
||||
),
|
||||
const Gap(16),
|
||||
ReactiveTextField<String>(
|
||||
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<String>(
|
||||
formControlName: 'password',
|
||||
decoration: const InputDecoration(
|
||||
labelText: '비밀번호',
|
||||
hintText: '8자 이상 입력',
|
||||
prefixIcon: Icon(Icons.lock_outlined),
|
||||
),
|
||||
obscureText: true,
|
||||
validationMessages: {
|
||||
'required': (_) => '비밀번호를 입력해주세요',
|
||||
'minLength': (_) => '비밀번호는 8자 이상이어야 합니다',
|
||||
},
|
||||
),
|
||||
const Gap(16),
|
||||
ReactiveTextField<String>(
|
||||
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('로그인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/features/dashboard/presentation/widgets/realtime_chart.dart
Normal file
182
lib/features/dashboard/presentation/widgets/realtime_chart.dart
Normal file
@@ -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<double>? dataStream;
|
||||
final int maxDataPoints;
|
||||
final Color? lineColor;
|
||||
|
||||
@override
|
||||
State<RealtimeChart> createState() => _RealtimeChartState();
|
||||
}
|
||||
|
||||
class _RealtimeChartState extends State<RealtimeChart> {
|
||||
final List<FlSpot> _spots = [];
|
||||
StreamSubscription<double>? _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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/features/dashboard/presentation/widgets/stats_card.dart
Normal file
105
lib/features/dashboard/presentation/widgets/stats_card.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
166
lib/features/user/presentation/screens/user_home_screen.dart
Normal file
166
lib/features/user/presentation/screens/user_home_screen.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
172
lib/features/user/presentation/screens/user_profile_screen.dart
Normal file
172
lib/features/user/presentation/screens/user_profile_screen.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/l10n/app_ko.arb
Normal file
39
lib/l10n/app_ko.arb
Normal file
@@ -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": "시스템 설정"
|
||||
}
|
||||
25
lib/main.dart
Normal file
25
lib/main.dart
Normal file
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
20
lib/shared/models/api_response.dart
Normal file
20
lib/shared/models/api_response.dart
Normal file
@@ -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<T> with _$ApiResponse<T> {
|
||||
const factory ApiResponse({
|
||||
required bool success,
|
||||
required T data,
|
||||
String? message,
|
||||
@Default(200) int statusCode,
|
||||
}) = _ApiResponse<T>;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object?) fromJsonT,
|
||||
) =>
|
||||
_$ApiResponseFromJson(json, fromJsonT);
|
||||
}
|
||||
23
lib/shared/models/pagination.dart
Normal file
23
lib/shared/models/pagination.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'pagination.freezed.dart';
|
||||
part 'pagination.g.dart';
|
||||
|
||||
@Freezed(genericArgumentFactories: true)
|
||||
class PaginatedResponse<T> with _$PaginatedResponse<T> {
|
||||
const factory PaginatedResponse({
|
||||
required List<T> 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<T>;
|
||||
|
||||
factory PaginatedResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object?) fromJsonT,
|
||||
) =>
|
||||
_$PaginatedResponseFromJson(json, fromJsonT);
|
||||
}
|
||||
18
lib/shared/models/user_role.dart
Normal file
18
lib/shared/models/user_role.dart
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/shared/providers/auth_provider.dart
Normal file
34
lib/shared/providers/auth_provider.dart
Normal file
@@ -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<User?> 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<void> logout() async {
|
||||
final repository = ref.read(authRepositoryProvider);
|
||||
await repository.logout();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
}
|
||||
14
lib/shared/providers/connectivity_provider.dart
Normal file
14
lib/shared/providers/connectivity_provider.dart
Normal file
@@ -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<bool> connectivityStatus(Ref ref) {
|
||||
return Connectivity().onConnectivityChanged.map(
|
||||
(results) => results.any(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
221
lib/shared/widgets/app_scaffold.dart
Normal file
221
lib/shared/widgets/app_scaffold.dart
Normal file
@@ -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<NavigationRailDestination> _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<NavigationDrawerDestination> _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('대시보드'),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
84
lib/shared/widgets/chart_widgets/bar_chart_widget.dart
Normal file
84
lib/shared/widgets/chart_widgets/bar_chart_widget.dart
Normal file
@@ -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<BarChartGroupData> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/shared/widgets/chart_widgets/line_chart_widget.dart
Normal file
117
lib/shared/widgets/chart_widgets/line_chart_widget.dart
Normal file
@@ -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<FlSpot> spots;
|
||||
final String? title;
|
||||
final Color? lineColor;
|
||||
final List<Color>? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/shared/widgets/chart_widgets/pie_chart_widget.dart
Normal file
100
lib/shared/widgets/chart_widgets/pie_chart_widget.dart
Normal file
@@ -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<PieChartSectionData> sections;
|
||||
final String? title;
|
||||
final String? centerText;
|
||||
final bool showLegend;
|
||||
final List<LegendItem>? 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;
|
||||
}
|
||||
66
lib/shared/widgets/confirm_dialog.dart
Normal file
66
lib/shared/widgets/confirm_dialog.dart
Normal file
@@ -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<bool?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmLabel = '확인',
|
||||
String cancelLabel = '취소',
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/shared/widgets/data_table_widget.dart
Normal file
49
lib/shared/widgets/data_table_widget.dart
Normal file
@@ -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<PlutoColumn> columns;
|
||||
final List<PlutoRow> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/shared/widgets/empty_state_widget.dart
Normal file
53
lib/shared/widgets/empty_state_widget.dart
Normal file
@@ -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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/shared/widgets/error_widget.dart
Normal file
49
lib/shared/widgets/error_widget.dart
Normal file
@@ -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('다시 시도'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/shared/widgets/loading_widget.dart
Normal file
41
lib/shared/widgets/loading_widget.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user