UI 전체 리디자인 및 개선사항 적용

## 주요 변경사항:

### UI/UX 개선
- shadcn/ui 스타일 기반의 새로운 디자인 시스템 도입
- 모든 주요 화면에 대한 리디자인 구현 완료
  - 로그인 화면: 모던한 카드 스타일 적용
  - 대시보드: 통계 카드와 차트를 활용한 개요 화면
  - 리스트 화면들: 일관된 테이블 디자인과 검색/필터 기능
- 다크모드 지원을 위한 테마 시스템 구축

### 기능 개선
- Equipment List: 고급 필터링 (상태, 담당자별)
- Company List: 검색 및 정렬 기능 강화
- User List: 역할별 필터링 추가
- License List: 만료일 기반 상태 표시
- Warehouse Location: 재고 수준 시각화

### 기술적 개선
- 재사용 가능한 컴포넌트 라이브러리 구축
- 일관된 코드 패턴 가이드라인 작성
- 프로젝트 구조 분석 및 문서화

### 문서화
- 프로젝트 분석 문서 추가
- UI 리디자인 진행 상황 문서
- 코드 패턴 가이드 작성
- Equipment 기능 격차 분석 및 구현 계획

### 삭제/리팩토링
- goods_list.dart 제거 (equipment_list로 통합)
- 불필요한 import 및 코드 정리

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-07 19:45:32 +09:00
parent e346f83c97
commit e0bc5894b2
34 changed files with 7764 additions and 571 deletions

View File

@@ -6,7 +6,6 @@ import 'package:superport/screens/company/company_list.dart';
import 'package:superport/screens/user/user_list.dart';
import 'package:superport/screens/license/license_list.dart';
import 'package:superport/screens/warehouse_location/warehouse_location_list.dart';
import 'package:superport/screens/goods/goods_list.dart';
import 'package:superport/utils/constants.dart';
/// SPA 스타일의 앱 레이아웃 클래스
@@ -41,8 +40,6 @@ class _AppLayoutState extends State<AppLayout> {
case Routes.equipmentRentList:
// 장비 목록 화면에 현재 라우트 정보를 전달
return EquipmentListScreen(currentRoute: route);
case Routes.goods:
return const GoodsListScreen();
case Routes.company:
return const CompanyListScreen();
case Routes.license:

View File

@@ -0,0 +1,574 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/overview/overview_screen_redesign.dart';
import 'package:superport/screens/equipment/equipment_list_redesign.dart';
import 'package:superport/screens/company/company_list_redesign.dart';
import 'package:superport/screens/user/user_list_redesign.dart';
import 'package:superport/screens/license/license_list_redesign.dart';
import 'package:superport/screens/warehouse_location/warehouse_location_list_redesign.dart';
import 'package:superport/utils/constants.dart';
/// Microsoft Dynamics 365 스타일의 메인 레이아웃
/// 상단 헤더 + 좌측 사이드바 + 메인 콘텐츠 구조
class AppLayoutRedesign extends StatefulWidget {
final String initialRoute;
const AppLayoutRedesign({Key? key, this.initialRoute = Routes.home})
: super(key: key);
@override
State<AppLayoutRedesign> createState() => _AppLayoutRedesignState();
}
class _AppLayoutRedesignState extends State<AppLayoutRedesign>
with TickerProviderStateMixin {
late String _currentRoute;
bool _sidebarCollapsed = false;
late AnimationController _sidebarAnimationController;
late Animation<double> _sidebarAnimation;
@override
void initState() {
super.initState();
_currentRoute = widget.initialRoute;
_setupAnimations();
}
void _setupAnimations() {
_sidebarAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_sidebarAnimation = Tween<double>(begin: 280.0, end: 72.0).animate(
CurvedAnimation(
parent: _sidebarAnimationController,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_sidebarAnimationController.dispose();
super.dispose();
}
/// 현재 경로에 따라 적절한 컨텐츠 섹션을 반환
Widget _getContentForRoute(String route) {
switch (route) {
case Routes.home:
return const OverviewScreenRedesign();
case Routes.equipment:
case Routes.equipmentInList:
case Routes.equipmentOutList:
case Routes.equipmentRentList:
return EquipmentListRedesign(currentRoute: route);
case Routes.company:
return const CompanyListRedesign();
case Routes.user:
return const UserListRedesign();
case Routes.license:
return const LicenseListRedesign();
case Routes.warehouseLocation:
return const WarehouseLocationListRedesign();
default:
return const OverviewScreenRedesign();
}
}
/// 경로 변경 메서드
void _navigateTo(String route) {
setState(() {
_currentRoute = route;
});
}
/// 사이드바 토글
void _toggleSidebar() {
setState(() {
_sidebarCollapsed = !_sidebarCollapsed;
});
if (_sidebarCollapsed) {
_sidebarAnimationController.forward();
} else {
_sidebarAnimationController.reverse();
}
}
/// 현재 페이지 제목 가져오기
String _getPageTitle() {
switch (_currentRoute) {
case Routes.home:
return '대시보드';
case Routes.equipment:
case Routes.equipmentInList:
case Routes.equipmentOutList:
case Routes.equipmentRentList:
return '장비 관리';
case Routes.company:
return '회사 관리';
case Routes.license:
return '유지보수 관리';
case Routes.warehouseLocation:
return '입고지 관리';
default:
return '대시보드';
}
}
/// 브레드크럼 경로 가져오기
List<String> _getBreadcrumbs() {
switch (_currentRoute) {
case Routes.home:
return ['', '대시보드'];
case Routes.equipment:
return ['', '장비 관리', '전체'];
case Routes.equipmentInList:
return ['', '장비 관리', '입고'];
case Routes.equipmentOutList:
return ['', '장비 관리', '출고'];
case Routes.equipmentRentList:
return ['', '장비 관리', '대여'];
case Routes.company:
return ['', '회사 관리'];
case Routes.license:
return ['', '유지보수 관리'];
case Routes.warehouseLocation:
return ['', '입고지 관리'];
default:
return ['', '대시보드'];
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ShadcnTheme.muted,
body: Column(
children: [
// 상단 헤더
_buildTopHeader(),
// 메인 콘텐츠 영역
Expanded(
child: Row(
children: [
// 좌측 사이드바
AnimatedBuilder(
animation: _sidebarAnimation,
builder: (context, child) {
return SizedBox(
width: _sidebarAnimation.value,
child: _buildSidebar(),
);
},
),
// 메인 콘텐츠
Expanded(
child: Container(
margin: const EdgeInsets.all(ShadcnTheme.spacing4),
decoration: BoxDecoration(
color: ShadcnTheme.background,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(color: ShadcnTheme.border),
boxShadow: ShadcnTheme.cardShadow,
),
child: Column(
children: [
// 페이지 헤더
_buildPageHeader(),
// 메인 콘텐츠
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(ShadcnTheme.radiusLg),
bottomRight: Radius.circular(
ShadcnTheme.radiusLg,
),
),
child: _getContentForRoute(_currentRoute),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// 상단 헤더 빌드
Widget _buildTopHeader() {
return Container(
height: 64,
decoration: BoxDecoration(
color: ShadcnTheme.background,
border: Border(bottom: BorderSide(color: ShadcnTheme.border)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4),
child: Row(
children: [
// 사이드바 토글 버튼
IconButton(
onPressed: _toggleSidebar,
icon: Icon(
_sidebarCollapsed ? Icons.menu : Icons.menu_open,
color: ShadcnTheme.foreground,
),
tooltip: _sidebarCollapsed ? '사이드바 펼치기' : '사이드바 접기',
),
const SizedBox(width: ShadcnTheme.spacing4),
// 앱 로고 및 제목
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing2),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [ShadcnTheme.gradient1, ShadcnTheme.gradient2],
),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Icon(
Icons.directions_boat,
size: 24,
color: ShadcnTheme.primaryForeground,
),
),
const SizedBox(width: ShadcnTheme.spacing3),
Text('supERPort', style: ShadcnTheme.headingH4),
const Spacer(),
// 상단 액션 버튼들
_buildTopActions(),
],
),
),
);
}
/// 상단 액션 버튼들
Widget _buildTopActions() {
return Row(
children: [
// 알림 버튼
Container(
decoration: BoxDecoration(
color: ShadcnTheme.muted,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: IconButton(
onPressed: () {
// 알림 기능
},
icon: Stack(
children: [
Icon(
Icons.notifications_outlined,
color: ShadcnTheme.foreground,
),
Positioned(
right: 0,
top: 0,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: ShadcnTheme.destructive,
shape: BoxShape.circle,
),
),
),
],
),
tooltip: '알림',
),
),
const SizedBox(width: ShadcnTheme.spacing2),
// 설정 버튼
Container(
decoration: BoxDecoration(
color: ShadcnTheme.muted,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: IconButton(
onPressed: () {
// 설정 기능
},
icon: Icon(Icons.settings_outlined, color: ShadcnTheme.foreground),
tooltip: '설정',
),
),
const SizedBox(width: ShadcnTheme.spacing4),
// 프로필 아바타
GestureDetector(
onTap: () {
_showProfileMenu(context);
},
child: ShadcnAvatar(initials: 'A', size: 36),
),
],
);
}
/// 사이드바 빌드
Widget _buildSidebar() {
return Container(
decoration: BoxDecoration(
color: ShadcnTheme.background,
border: Border(right: BorderSide(color: ShadcnTheme.border)),
),
child: SidebarMenuRedesign(
currentRoute: _currentRoute,
onRouteChanged: _navigateTo,
collapsed: _sidebarCollapsed,
),
);
}
/// 페이지 헤더 빌드
Widget _buildPageHeader() {
final breadcrumbs = _getBreadcrumbs();
return Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: ShadcnTheme.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 브레드크럼
Row(
children: [
for (int i = 0; i < breadcrumbs.length; i++) ...[
if (i > 0) ...[
const SizedBox(width: ShadcnTheme.spacing2),
Icon(
Icons.chevron_right,
size: 16,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(width: ShadcnTheme.spacing2),
],
Text(
breadcrumbs[i],
style:
i == breadcrumbs.length - 1
? ShadcnTheme.bodyMedium
: ShadcnTheme.bodyMuted,
),
],
],
),
],
),
);
}
/// 프로필 메뉴 표시
void _showProfileMenu(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: ShadcnTheme.background,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(ShadcnTheme.radiusXl),
),
),
builder:
(context) => Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 프로필 정보
Row(
children: [
ShadcnAvatar(initials: 'A', size: 48),
const SizedBox(width: ShadcnTheme.spacing4),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('관리자', style: ShadcnTheme.headingH4),
Text(
'admin@superport.com',
style: ShadcnTheme.bodyMuted,
),
],
),
],
),
const SizedBox(height: ShadcnTheme.spacing6),
const ShadcnSeparator(),
const SizedBox(height: ShadcnTheme.spacing4),
// 로그아웃 버튼
ShadcnButton(
text: '로그아웃',
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pushReplacementNamed('/login');
},
variant: ShadcnButtonVariant.destructive,
fullWidth: true,
icon: Icon(Icons.logout),
),
],
),
),
);
}
}
/// 재설계된 사이드바 메뉴 (접기/펼치기 지원)
class SidebarMenuRedesign extends StatelessWidget {
final String currentRoute;
final Function(String) onRouteChanged;
final bool collapsed;
const SidebarMenuRedesign({
Key? key,
required this.currentRoute,
required this.onRouteChanged,
required this.collapsed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Column(
children: [
_buildMenuItem(
icon: Icons.dashboard,
title: '대시보드',
route: Routes.home,
isActive: currentRoute == Routes.home,
),
const SizedBox(height: ShadcnTheme.spacing2),
_buildMenuItem(
icon: Icons.inventory,
title: '장비 관리',
route: Routes.equipment,
isActive: [
Routes.equipment,
Routes.equipmentInList,
Routes.equipmentOutList,
Routes.equipmentRentList,
].contains(currentRoute),
),
const SizedBox(height: ShadcnTheme.spacing2),
_buildMenuItem(
icon: Icons.location_on,
title: '입고지 관리',
route: Routes.warehouseLocation,
isActive: currentRoute == Routes.warehouseLocation,
),
const SizedBox(height: ShadcnTheme.spacing2),
_buildMenuItem(
icon: Icons.business,
title: '회사 관리',
route: Routes.company,
isActive: currentRoute == Routes.company,
),
const SizedBox(height: ShadcnTheme.spacing2),
_buildMenuItem(
icon: Icons.vpn_key,
title: '유지보수 관리',
route: Routes.license,
isActive: currentRoute == Routes.license,
),
],
),
),
),
],
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
required String route,
required bool isActive,
}) {
return GestureDetector(
onTap: () => onRouteChanged(route),
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: collapsed ? ShadcnTheme.spacing2 : ShadcnTheme.spacing4,
vertical: ShadcnTheme.spacing3,
),
decoration: BoxDecoration(
color: isActive ? ShadcnTheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Row(
children: [
Icon(
icon,
size: 20,
color:
isActive
? ShadcnTheme.primaryForeground
: ShadcnTheme.foreground,
),
if (!collapsed) ...[
const SizedBox(width: ShadcnTheme.spacing3),
Expanded(
child: Text(
title,
style: ShadcnTheme.bodyMedium.copyWith(
color:
isActive
? ShadcnTheme.primaryForeground
: ShadcnTheme.foreground,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,509 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
/// shadcn/ui 스타일 기본 컴포넌트들
// 카드 컴포넌트
class ShadcnCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final VoidCallback? onTap;
const ShadcnCard({
Key? key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final cardContent = Container(
width: width,
height: height,
padding: padding ?? const EdgeInsets.all(ShadcnTheme.spacing6),
margin: margin,
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(color: ShadcnTheme.border),
boxShadow: ShadcnTheme.cardShadow,
),
child: child,
);
if (onTap != null) {
return GestureDetector(onTap: onTap, child: cardContent);
}
return cardContent;
}
}
// 버튼 컴포넌트
class ShadcnButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final ShadcnButtonVariant variant;
final ShadcnButtonSize size;
final Widget? icon;
final bool fullWidth;
final bool loading;
final Color? backgroundColor;
final Color? textColor;
const ShadcnButton({
Key? key,
required this.text,
this.onPressed,
this.variant = ShadcnButtonVariant.primary,
this.size = ShadcnButtonSize.medium,
this.icon,
this.fullWidth = false,
this.loading = false,
this.backgroundColor,
this.textColor,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final ButtonStyle style = _getButtonStyle();
final EdgeInsetsGeometry padding = _getPadding();
Widget buttonChild = Row(
mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (loading)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
textColor ?? _getDefaultTextColor(),
),
),
)
else if (icon != null)
icon!,
if ((loading || icon != null) && text.isNotEmpty)
const SizedBox(width: ShadcnTheme.spacing2),
if (text.isNotEmpty) Text(text, style: _getTextStyle()),
],
);
if (variant == ShadcnButtonVariant.primary) {
return SizedBox(
width: fullWidth ? double.infinity : null,
child: ElevatedButton(
onPressed: loading ? null : onPressed,
style: style.copyWith(padding: WidgetStateProperty.all(padding)),
child: buttonChild,
),
);
} else if (variant == ShadcnButtonVariant.secondary) {
return SizedBox(
width: fullWidth ? double.infinity : null,
child: OutlinedButton(
onPressed: loading ? null : onPressed,
style: style.copyWith(padding: WidgetStateProperty.all(padding)),
child: buttonChild,
),
);
} else {
return SizedBox(
width: fullWidth ? double.infinity : null,
child: TextButton(
onPressed: loading ? null : onPressed,
style: style.copyWith(padding: WidgetStateProperty.all(padding)),
child: buttonChild,
),
);
}
}
ButtonStyle _getButtonStyle() {
switch (variant) {
case ShadcnButtonVariant.primary:
return ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? ShadcnTheme.primary,
foregroundColor: textColor ?? ShadcnTheme.primaryForeground,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
);
case ShadcnButtonVariant.secondary:
return OutlinedButton.styleFrom(
backgroundColor: backgroundColor ?? ShadcnTheme.secondary,
foregroundColor: textColor ?? ShadcnTheme.secondaryForeground,
side: const BorderSide(color: ShadcnTheme.border),
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
);
case ShadcnButtonVariant.destructive:
return ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? ShadcnTheme.destructive,
foregroundColor: textColor ?? ShadcnTheme.destructiveForeground,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
);
case ShadcnButtonVariant.ghost:
return TextButton.styleFrom(
backgroundColor: backgroundColor ?? Colors.transparent,
foregroundColor: textColor ?? ShadcnTheme.foreground,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
);
}
}
EdgeInsetsGeometry _getPadding() {
switch (size) {
case ShadcnButtonSize.small:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing1,
);
case ShadcnButtonSize.medium:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: ShadcnTheme.spacing2,
);
case ShadcnButtonSize.large:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing8,
vertical: ShadcnTheme.spacing3,
);
}
}
TextStyle _getTextStyle() {
TextStyle baseStyle;
switch (size) {
case ShadcnButtonSize.small:
baseStyle = ShadcnTheme.labelSmall;
break;
case ShadcnButtonSize.medium:
baseStyle = ShadcnTheme.labelMedium;
break;
case ShadcnButtonSize.large:
baseStyle = ShadcnTheme.labelLarge;
break;
}
return textColor != null ? baseStyle.copyWith(color: textColor) : baseStyle;
}
Color _getDefaultTextColor() {
switch (variant) {
case ShadcnButtonVariant.primary:
return ShadcnTheme.primaryForeground;
case ShadcnButtonVariant.secondary:
return ShadcnTheme.secondaryForeground;
case ShadcnButtonVariant.destructive:
return ShadcnTheme.destructiveForeground;
case ShadcnButtonVariant.ghost:
return ShadcnTheme.foreground;
}
}
}
// 버튼 variants
enum ShadcnButtonVariant { primary, secondary, destructive, ghost }
// 버튼 사이즈
enum ShadcnButtonSize { small, medium, large }
// 입력 필드 컴포넌트
class ShadcnInput extends StatelessWidget {
final String? label;
final String? placeholder;
final String? errorText;
final TextEditingController? controller;
final bool obscureText;
final TextInputType? keyboardType;
final ValueChanged<String>? onChanged;
final VoidCallback? onTap;
final Widget? prefixIcon;
final Widget? suffixIcon;
final bool readOnly;
final bool enabled;
final int? maxLines;
const ShadcnInput({
Key? key,
this.label,
this.placeholder,
this.errorText,
this.controller,
this.obscureText = false,
this.keyboardType,
this.onChanged,
this.onTap,
this.prefixIcon,
this.suffixIcon,
this.readOnly = false,
this.enabled = true,
this.maxLines = 1,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null) ...[
Text(label!, style: ShadcnTheme.labelMedium),
const SizedBox(height: ShadcnTheme.spacing1),
],
TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
onChanged: onChanged,
onTap: onTap,
readOnly: readOnly,
enabled: enabled,
maxLines: maxLines,
decoration: InputDecoration(
hintText: placeholder,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
errorText: errorText,
filled: true,
fillColor: ShadcnTheme.background,
contentPadding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing2,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
borderSide: const BorderSide(color: ShadcnTheme.input),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
borderSide: const BorderSide(color: ShadcnTheme.input),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
borderSide: const BorderSide(color: ShadcnTheme.ring, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
borderSide: const BorderSide(color: ShadcnTheme.destructive),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
borderSide: const BorderSide(
color: ShadcnTheme.destructive,
width: 2,
),
),
hintStyle: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
),
],
);
}
}
// 배지 컴포넌트
class ShadcnBadge extends StatelessWidget {
final String text;
final ShadcnBadgeVariant variant;
final ShadcnBadgeSize size;
const ShadcnBadge({
Key? key,
required this.text,
this.variant = ShadcnBadgeVariant.primary,
this.size = ShadcnBadgeSize.medium,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: _getPadding(),
decoration: BoxDecoration(
color: _getBackgroundColor(),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusXl),
border: Border.all(color: _getBorderColor()),
),
child: Text(text, style: _getTextStyle()),
);
}
EdgeInsetsGeometry _getPadding() {
switch (size) {
case ShadcnBadgeSize.small:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing1,
vertical: ShadcnTheme.spacing1 / 2,
);
case ShadcnBadgeSize.medium:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing2,
vertical: ShadcnTheme.spacing1,
);
case ShadcnBadgeSize.large:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing1,
);
}
}
Color _getBackgroundColor() {
switch (variant) {
case ShadcnBadgeVariant.primary:
return ShadcnTheme.primary;
case ShadcnBadgeVariant.secondary:
return ShadcnTheme.secondary;
case ShadcnBadgeVariant.destructive:
return ShadcnTheme.destructive;
case ShadcnBadgeVariant.success:
return ShadcnTheme.success;
case ShadcnBadgeVariant.warning:
return ShadcnTheme.warning;
case ShadcnBadgeVariant.outline:
return Colors.transparent;
}
}
Color _getBorderColor() {
switch (variant) {
case ShadcnBadgeVariant.outline:
return ShadcnTheme.border;
default:
return Colors.transparent;
}
}
TextStyle _getTextStyle() {
final Color textColor =
variant == ShadcnBadgeVariant.outline
? ShadcnTheme.foreground
: variant == ShadcnBadgeVariant.secondary
? ShadcnTheme.secondaryForeground
: ShadcnTheme.primaryForeground;
switch (size) {
case ShadcnBadgeSize.small:
return ShadcnTheme.labelSmall.copyWith(color: textColor);
case ShadcnBadgeSize.medium:
return ShadcnTheme.labelMedium.copyWith(color: textColor);
case ShadcnBadgeSize.large:
return ShadcnTheme.labelLarge.copyWith(color: textColor);
}
}
}
// 배지 variants
enum ShadcnBadgeVariant {
primary,
secondary,
destructive,
success,
warning,
outline,
}
// 배지 사이즈
enum ShadcnBadgeSize { small, medium, large }
// 구분선 컴포넌트
class ShadcnSeparator extends StatelessWidget {
final Axis direction;
final double thickness;
final Color? color;
const ShadcnSeparator({
Key? key,
this.direction = Axis.horizontal,
this.thickness = 1.0,
this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: direction == Axis.horizontal ? double.infinity : thickness,
height: direction == Axis.vertical ? double.infinity : thickness,
color: color ?? ShadcnTheme.border,
);
}
}
// 아바타 컴포넌트
class ShadcnAvatar extends StatelessWidget {
final String? imageUrl;
final String? initials;
final double size;
final Color? backgroundColor;
const ShadcnAvatar({
Key? key,
this.imageUrl,
this.initials,
this.size = 40,
this.backgroundColor,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: backgroundColor ?? ShadcnTheme.muted,
shape: BoxShape.circle,
border: Border.all(color: ShadcnTheme.border),
),
child: ClipOval(
child:
imageUrl != null
? Image.network(
imageUrl!,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => _buildFallback(),
)
: _buildFallback(),
),
);
}
Widget _buildFallback() {
return Container(
color: backgroundColor ?? ShadcnTheme.muted,
child: Center(
child: Text(
initials ?? '?',
style: ShadcnTheme.labelMedium.copyWith(
color: ShadcnTheme.mutedForeground,
fontSize: size * 0.4,
),
),
),
);
}
}

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// shadcn/ui 스타일 테마 시스템
class ShadcnTheme {
// shadcn/ui 색상 시스템
static const Color background = Color(0xFFFFFFFF);
static const Color foreground = Color(0xFF020817);
static const Color card = Color(0xFFFFFFFF);
static const Color cardForeground = Color(0xFF020817);
static const Color popover = Color(0xFFFFFFFF);
static const Color popoverForeground = Color(0xFF020817);
static const Color primary = Color(0xFF0F172A);
static const Color primaryForeground = Color(0xFFF8FAFC);
static const Color secondary = Color(0xFFF1F5F9);
static const Color secondaryForeground = Color(0xFF0F172A);
static const Color muted = Color(0xFFF1F5F9);
static const Color mutedForeground = Color(0xFF64748B);
static const Color accent = Color(0xFFF1F5F9);
static const Color accentForeground = Color(0xFF0F172A);
static const Color destructive = Color(0xFFEF4444);
static const Color destructiveForeground = Color(0xFFF8FAFC);
static const Color border = Color(0xFFE2E8F0);
static const Color input = Color(0xFFE2E8F0);
static const Color ring = Color(0xFF020817);
static const Color radius = Color(0xFF000000); // 사용하지 않음
// 그라데이션 색상
static const Color gradient1 = Color(0xFF6366F1);
static const Color gradient2 = Color(0xFF8B5CF6);
static const Color gradient3 = Color(0xFFEC4899);
// 상태 색상
static const Color success = Color(0xFF10B981);
static const Color warning = Color(0xFFF59E0B);
static const Color error = Color(0xFFEF4444);
static const Color info = Color(0xFF3B82F6);
// 그림자 설정
static List<BoxShadow> get cardShadow => [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
];
static List<BoxShadow> get buttonShadow => [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 3,
offset: const Offset(0, 1),
),
];
// 간격 시스템
static const double spacing1 = 4.0;
static const double spacing2 = 8.0;
static const double spacing3 = 12.0;
static const double spacing4 = 16.0;
static const double spacing5 = 20.0;
static const double spacing6 = 24.0;
static const double spacing8 = 32.0;
static const double spacing10 = 40.0;
static const double spacing12 = 48.0;
static const double spacing16 = 64.0;
static const double spacing20 = 80.0;
// 라운드 설정
static const double radiusNone = 0.0;
static const double radiusSm = 2.0;
static const double radiusMd = 6.0;
static const double radiusLg = 8.0;
static const double radiusXl = 12.0;
static const double radius2xl = 16.0;
static const double radius3xl = 24.0;
static const double radiusFull = 9999.0;
// 타이포그래피 시스템
static TextStyle get headingH1 => GoogleFonts.inter(
fontSize: 36,
fontWeight: FontWeight.w800,
color: foreground,
letterSpacing: -0.02,
);
static TextStyle get headingH2 => GoogleFonts.inter(
fontSize: 30,
fontWeight: FontWeight.w700,
color: foreground,
letterSpacing: -0.02,
);
static TextStyle get headingH3 => GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: foreground,
letterSpacing: -0.01,
);
static TextStyle get headingH4 => GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: foreground,
letterSpacing: -0.01,
);
static TextStyle get bodyLarge => GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: foreground,
letterSpacing: 0,
);
static TextStyle get bodyMedium => GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: foreground,
letterSpacing: 0,
);
static TextStyle get bodySmall => GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: mutedForeground,
letterSpacing: 0,
);
static TextStyle get bodyMuted => GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: mutedForeground,
letterSpacing: 0,
);
static TextStyle get labelLarge => GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: foreground,
letterSpacing: 0,
);
static TextStyle get labelMedium => GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: foreground,
letterSpacing: 0,
);
static TextStyle get labelSmall => GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
color: mutedForeground,
letterSpacing: 0,
);
// Flutter 테마 데이터
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: primary,
secondary: secondary,
surface: background,
surfaceContainerHighest: card,
onSurface: foreground,
onPrimary: primaryForeground,
onSecondary: secondaryForeground,
error: destructive,
onError: destructiveForeground,
outline: border,
outlineVariant: input,
),
scaffoldBackgroundColor: background,
textTheme: TextTheme(
headlineLarge: headingH1,
headlineMedium: headingH2,
headlineSmall: headingH3,
titleLarge: headingH4,
bodyLarge: bodyLarge,
bodyMedium: bodyMedium,
bodySmall: bodySmall,
labelLarge: labelLarge,
labelMedium: labelMedium,
labelSmall: labelSmall,
),
appBarTheme: AppBarTheme(
backgroundColor: background,
foregroundColor: foreground,
elevation: 0,
scrolledUnderElevation: 1,
shadowColor: Colors.black.withOpacity(0.1),
surfaceTintColor: Colors.transparent,
titleTextStyle: headingH4,
iconTheme: const IconThemeData(color: foreground),
),
cardTheme: CardTheme(
color: card,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: border, width: 1),
),
shadowColor: Colors.black.withOpacity(0.05),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: primaryForeground,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
padding: const EdgeInsets.symmetric(
horizontal: spacing4,
vertical: spacing2,
),
textStyle: labelMedium,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: foreground,
side: const BorderSide(color: border),
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
padding: const EdgeInsets.symmetric(
horizontal: spacing4,
vertical: spacing2,
),
textStyle: labelMedium,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: foreground,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
padding: const EdgeInsets.symmetric(
horizontal: spacing4,
vertical: spacing2,
),
textStyle: labelMedium,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: background,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing3,
vertical: spacing2,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: input),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: input),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: ring, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: destructive),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: destructive, width: 2),
),
hintStyle: bodyMedium.copyWith(color: mutedForeground),
labelStyle: labelMedium,
),
dividerTheme: const DividerThemeData(color: border, thickness: 1),
);
}
}