초기 커밋

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

View File

@@ -0,0 +1,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);
}

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

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

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

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

View 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('대시보드'),
),
];
}
}

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

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

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

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

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

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

View 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('다시 시도'),
),
],
],
),
),
);
}
}

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