초기 커밋
This commit is contained in:
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