Files
superport/lib/screens/common/components/shadcn_components.dart
JiWoong Sul b8f10dd588 refactor: UI 일관성 개선 및 테이블 구조 통일
장비관리 화면을 기준으로 전체 화면 UI 일관성 개선:

- 모든 화면 검색바/버튼/드롭다운 높이 40px 통일
- 테이블 헤더 패딩 vertical 10px, 행 패딩 vertical 4px 통일
- 배지 스타일 통일 (border 제거, opacity 0.9 적용)
- 페이지네이션 10개 이하에서도 항상 표시
- 테이블 헤더 폰트 스타일 통일 (fontSize: 13, fontWeight: w500)

각 화면별 수정사항:
1. 장비관리: 컬럼 너비 최적화, 검색 컴포넌트 높이 명시
2. 입고지 관리: 페이지네이션 조건 개선
3. 회사관리: UnifiedSearchBar 통합, 배지 스타일 개선
4. 유지보수: ListView.builder → map() 변경, 테이블 구조 재설계

키포인트 색상을 teal로 통일하여 브랜드 일관성 확보
2025-08-08 18:03:07 +09:00

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: Colors.black),
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: Colors.black),
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: 6,
);
case ShadcnButtonSize.medium:
return const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 10,
);
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: 10,
),
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.withValues(alpha: 0.8),
),
),
),
],
);
}
}
// 배지 컴포넌트
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 Colors.black;
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 ?? Colors.black,
);
}
}
// 아바타 컴포넌트
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: Colors.black),
),
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,
),
),
),
);
}
}