## 주요 변경사항: ### 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>
510 lines
14 KiB
Dart
510 lines
14 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|