![[Flutter + Firebase로 북마크 앱 만들기 #9] 모던 UI 디자인 시스템 구축](https://image.inblog.dev?url=https%3A%2F%2Finblog.ai%2Fapi%2Fog%3Ftitle%3D%255BFlutter%2520%252B%2520Firebase%25EB%25A1%259C%2520%25EB%25B6%2581%25EB%25A7%2588%25ED%2581%25AC%2520%25EC%2595%25B1%2520%25EB%25A7%258C%25EB%2593%25A4%25EA%25B8%25B0%2520%25239%255D%2520%25EB%25AA%25A8%25EB%258D%2598%2520UI%2520%25EB%2594%2594%25EC%259E%2590%25EC%259D%25B8%2520%25EC%258B%259C%25EC%258A%25A4%25ED%2585%259C%2520%25EA%25B5%25AC%25EC%25B6%2595%26logoUrl%3Dhttps%253A%252F%252Finblog.ai%252Finblog_logo.png%26blogTitle%3DGyeongwon%27s%2520blog&w=2048&q=75)
📌 시리즈 목차
- #9 모던 UI 디자인 시스템 구축 ← 현재 글
🎯 이번 글에서 다룬 내용
북마크 앱의 UI를 완전히 새롭게 디자인했습니다!
구현한 기능
✅ Material 3 색상 시스템 (에메랄드 그린)
✅ Google Fonts 타이포그래피 (Noto Sans)
✅ 세련된 BookmarkCard 리디자인
✅ 그라데이션 AddBookmarkDialog
✅ 개선된 CategoryFilterSection
✅ 통일성 있는 아이콘 시스템
✅ 마이크로 애니메이션 추가
✅ 커스텀 그림자 시스템
개선 결과
Before (구식) After (모던)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔵 파란색 🟢 에메랄드 그린
시스템 폰트 Noto Sans (한글 최적화)
elevation: 2 커스텀 그림자
borderRadius: 12 borderRadius: 16
padding: 16 padding: 20
단색 배경 그라데이션
평범한 버튼 애니메이션 버튼
들쭉날쭉 레이아웃 통일된 레이아웃
🎨 1단계: 색상 시스템 전면 개편
기존의 문제점
// Before: 평범한 파란색
class AppColors {
static const Color primary = Color(0xFF2196F3);
static const Color primaryLight = Color(0xFFE3F2FD);
static const Color error = Color(0xFFE57373);
static const Color success = Color(0xFF81C784);
}
문제점:
- ❌ 너무 흔한 Material 파란색
- ❌ 단색만 있고 그라데이션 없음
- ❌ 그림자 스타일 정의 없음
- ❌ SNS 브랜드 컬러 없음
- ❌ 다크모드 미지원
새로운 색상 시스템
위치:
lib/core/constants/app_colors.dart
import 'package:flutter/material.dart';
class AppColors {
// ============= Primary Colors (Emerald Green) =============
static const Color primary = Color(0xFF10B981); // 에메랄드 그린
static const Color primaryLight = Color(0xFF6EE7B7); // 밝은 민트
static const Color primaryDark = Color(0xFF059669); // 진한 에메랄드
static const Color primaryContainer = Color(0xFFD1FAE5);
// ============= Secondary Colors =============
static const Color secondary = Color(0xFF8B5CF6); // 보라색 악센트
static const Color secondaryLight = Color(0xFFC4B5FD);
static const Color secondaryContainer = Color(0xFFEDE9FE);
// ============= Background Colors =============
static const Color background = Color(0xFFFAFAFA); // 더 밝은 회색
static const Color surface = Colors.white;
static const Color surfaceVariant = Color(0xFFF5F5F5);
// ============= Text Colors =============
static const Color textPrimary = Color(0xFF1A1A1A); // 더 진한 검정
static const Color textSecondary = Color(0xFF616161); // 중간 회색
static const Color textTertiary = Color(0xFF9E9E9E); // 연한 회색
static const Color textOnPrimary = Colors.white;
// ============= Status Colors =============
static const Color error = Color(0xFFEF5350);
static const Color errorLight = Color(0xFFFFCDD2);
static const Color success = Color(0xFF66BB6A);
static const Color successLight = Color(0xFFC8E6C9);
static const Color warning = Color(0xFFFFA726);
static const Color warningLight = Color(0xFFFFE0B2);
static const Color info = Color(0xFF3B82F6);
static const Color infoLight = Color(0xFFBFDBFE);
// ============= Border & Divider =============
static const Color border = Color(0xFFE0E0E0);
static const Color divider = Color(0xFFBDBDBD);
// ============= SNS Colors =============
static const Color youtube = Color(0xFFFF0000);
static const Color instagram = Color(0xFFE4405F);
static const Color twitter = Color(0xFF1DA1F2);
static const Color facebook = Color(0xFF1877F2);
static const Color tiktok = Color(0xFF000000);
// ============= Gradients =============
static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFF10B981), Color(0xFF6EE7B7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
static const LinearGradient secondaryGradient = LinearGradient(
colors: [Color(0xFF8B5CF6), Color(0xFFC4B5FD)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
static const LinearGradient surfaceGradient = LinearGradient(
colors: [Colors.white, Color(0xFFF5F5F5)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
// ============= Shadows =============
static List<BoxShadow> cardShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
];
static List<BoxShadow> elevatedShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 20,
offset: const Offset(0, 8),
),
];
static List<BoxShadow> softShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
];
}
색상 선택 과정
여러 색상 팔레트를 검토했습니다:
색상 | 느낌 | 점수 |
🟢 에메랄드 그린 | 신선하고 자연스러움 | ⭐⭐⭐⭐⭐ |
🟣 인디고 퍼플 | 고급스럽고 세련됨 | ⭐⭐⭐⭐⭐ |
🩷 코랄 핑크 | 따뜻하고 친근함 | ⭐⭐⭐⭐ |
🟠 앰버 오렌지 | 밝고 에너지 넘침 | ⭐⭐⭐ |
🩵 티얼 시안 | 차분하고 현대적 | ⭐⭐⭐⭐ |
에메랄드 그린을 선택한 이유:
✅ 북마크 = 저장/보관 → 자연/신뢰 연상
✅ 눈의 피로도가 적음
✅ 신선하고 모던한 느낌
✅ Notion, Evernote 같은 생산성 앱 느낌
✅ 파란색과 완전히 다른 차별화
✍️ 2단계: 타이포그래피 시스템
Google Fonts 설치
1. pubspec.yaml 수정:
dependencies:
flutter:
sdk: flutter
google_fonts: ^6.3.0 # 추가
2. 설치:
flutter pub add google_fonts
타이포그래피 정의
위치:
lib/core/constants/app_text_styles.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
class AppTextStyles {
// ============= Headings =============
static TextStyle h1 = GoogleFonts.notoSans(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
height: 1.2,
);
static TextStyle h2 = GoogleFonts.notoSans(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
height: 1.3,
);
static TextStyle h3 = GoogleFonts.notoSans(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
height: 1.4,
);
static TextStyle h4 = GoogleFonts.notoSans(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
height: 1.4,
);
// ============= Body =============
static TextStyle bodyLarge = GoogleFonts.notoSans(
fontSize: 16,
fontWeight: FontWeight.normal,
color: AppColors.textPrimary,
height: 1.5,
);
static TextStyle bodyMedium = GoogleFonts.notoSans(
fontSize: 14,
fontWeight: FontWeight.normal,
color: AppColors.textPrimary,
height: 1.5,
);
static TextStyle bodySmall = GoogleFonts.notoSans(
fontSize: 12,
fontWeight: FontWeight.normal,
color: AppColors.textSecondary,
height: 1.5,
);
// ============= Labels =============
static TextStyle labelLarge = GoogleFonts.notoSans(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
letterSpacing: 0.1,
);
static TextStyle labelMedium = GoogleFonts.notoSans(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
letterSpacing: 0.5,
);
static TextStyle labelSmall = GoogleFonts.notoSans(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
letterSpacing: 0.5,
);
// ============= Special =============
static TextStyle caption = GoogleFonts.notoSans(
fontSize: 12,
fontWeight: FontWeight.normal,
color: AppColors.textTertiary,
height: 1.4,
);
static TextStyle button = GoogleFonts.notoSans(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
);
static TextStyle link = GoogleFonts.notoSans(
fontSize: 14,
fontWeight: FontWeight.normal,
color: AppColors.primary,
decoration: TextDecoration.underline,
);
}
Noto Sans를 선택한 이유
✅ 한글 최적화 (Google이 직접 한글 디자인)
✅ 다양한 굵기 지원 (Thin ~ Black)
✅ 라틴 문자와의 조화
✅ 무료 오픈소스
✅ 가독성 우수
대안 폰트:
- Pretendard: 한글 전용, 더 모던
- Spoqa Han Sans: 깔끔하지만 굵기 제한적
- Nanum Gothic: 전통적이지만 구식
🎴 3단계: BookmarkCard 완전 리디자인
Before (구식 디자인)
Card(
elevation: 2, // 낮은 그림자
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16), // 좁은 패딩
child: Column(
children: [
// 아이콘 + 제목 + 메뉴
Row(...),
// URL
Text(url),
// 카테고리
Chip(...),
],
),
),
),
)
문제점:
- ❌ 낮은 elevation (2)
- ❌ 좁은 패딩 (16px)
- ❌ 호버 효과 없음
- ❌ 아이콘 위치 불규칙
- ❌ 단조로운 디자인
After (모던 디자인)
위치:
lib/features/bookmarks/widgets/bookmark_card.dart
주요 개선 사항
1. StatefulWidget으로 변경 + 호버 애니메이션
class BookmarkCard extends StatefulWidget {
// ...
}
class _BookmarkCardState extends State<BookmarkCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final urlType = UrlLauncherHelper.detectUrlType(widget.bookmark.url);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 16),
child: Material(
elevation: _isHovered ? 8 : 0,
borderRadius: BorderRadius.circular(16),
shadowColor: Colors.black.withOpacity(0.1),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isHovered
? AppColors.primary.withOpacity(0.3)
: AppColors.border,
width: 1,
),
boxShadow: _isHovered
? AppColors.elevatedShadow
: AppColors.softShadow,
),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(20), // 16 → 20
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(urlType),
const SizedBox(height: 12),
_buildUrl(),
if (widget.bookmark.description.isNotEmpty) ...[
const SizedBox(height: 12),
_buildDescription(),
],
const SizedBox(height: 16),
_buildFooter(),
],
),
),
),
),
),
),
);
}
}
2. 통일된 아이콘 시스템
모든 북마크에 아이콘을 표시해서 레이아웃 통일성 확보!
Widget _buildHeader(UrlType urlType) {
// URL 타입에 따른 아이콘과 색상 결정
final IconData icon;
final Color? color;
final bool isGeneral = urlType == UrlType.general;
if (isGeneral) {
icon = Icons.language_rounded; // 일반 웹사이트 🌐
color = AppColors.primary; // 에메랄드 그린
} else {
icon = UrlLauncherHelper.getIcon(urlType);
color = UrlLauncherHelper.getColor(urlType);
}
return Row(
children: [
// URL 타입 아이콘 (항상 표시!)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color!.withOpacity(0.2),
color.withOpacity(0.1),
],
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 18,
color: color,
),
),
const SizedBox(width: 12),
// 제목
Expanded(
child: Text(
widget.bookmark.title,
style: AppTextStyles.h4.copyWith(
fontSize: 17,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
// 액션 메뉴
_buildActionMenu(),
],
);
}
아이콘 매핑:
URL 타입 | 아이콘 | 색상 |
YouTube | ▶️ play_circle_filled | 빨간색 |
Instagram | 📷 camera_alt | 핑크색 |
Twitter | @ alternate_email | 파란색 |
Facebook | f facebook | 파란색 |
TikTok | ♪ music_note | 검정색 |
General | 🌐 language_rounded | 에메랄드 그린 |
3. 세련된 URL 표시
Widget _buildUrl() {
return InkWell(
onTap: () => UrlLauncherHelper.openUrl(widget.bookmark.url, context),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.primaryContainer.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.link_rounded,
size: 16,
color: AppColors.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.bookmark.url,
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
4. 그라데이션 카테고리 뱃지
Widget _buildFooter() {
return Row(
children: [
// 카테고리 뱃지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_rounded,
size: 14,
color: Colors.white,
),
const SizedBox(width: 6),
Text(
widget.bookmark.category,
style: AppTextStyles.labelSmall.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
const Spacer(),
// 날짜
Row(
children: [
Icon(
Icons.access_time_rounded,
size: 14,
color: AppColors.textTertiary,
),
const SizedBox(width: 4),
Text(
_formatDate(widget.bookmark.createdAt),
style: AppTextStyles.caption.copyWith(
fontSize: 13,
),
),
],
),
],
);
}
5. 고급스러운 액션 메뉴
Widget _buildActionMenu() {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton(
icon: Icon(
Icons.more_vert,
color: AppColors.textSecondary,
size: 20,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 8,
itemBuilder: (context) => [
PopupMenuItem(
value: 'copy',
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.infoLight,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.copy_rounded,
size: 18,
color: AppColors.info,
),
),
const SizedBox(width: 12),
Text('URL 복사', style: AppTextStyles.bodyMedium),
],
),
),
PopupMenuItem(
value: 'edit',
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.edit_rounded,
size: 18,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Text('수정', style: AppTextStyles.bodyMedium),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.delete_rounded,
size: 18,
color: AppColors.error,
),
),
const SizedBox(width: 12),
Text(
'삭제',
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.error,
),
),
],
),
),
],
onSelected: (value) {
if (value == 'copy') {
Clipboard.setData(ClipboardData(text: widget.bookmark.url));
Fluttertoast.showToast(
msg: 'URL이 복사되었습니다',
backgroundColor: AppColors.success,
);
} else if (value == 'edit') {
widget.onEdit();
} else if (value == 'delete') {
widget.onDelete();
}
},
),
);
}
최종 카드 레이아웃
┌────────────────────────────────────────┐
│ [🌐] Flutter 공식 문서 [⋮] │ ← 아이콘 통일
│ │
│ 🔗 https://flutter.dev │ ← URL 박스
│ │
│ Flutter 프레임워크 공식 문서. │ ← 설명
│ 다양한 튜토리얼과 API 레퍼런스 │
│ │
│ [💚 개발] 2일 전 │ ← 그라데이션 뱃지
└────────────────────────────────────────┘
호버 시:
┌────────────────────────────────────────┐
│ [▶️] 재미있는 유튜브 영상 [⋮] │
│ ↑ 그림자 증가, 테두리 강조 │
└────────────────────────────────────────┘
💬 4단계: AddBookmarkDialog 리디자인
Before vs After
Before (평범):
┌──────────────────────┐
│ 북마크 추가 ✖ │
├──────────────────────┤
│ [URL 입력] │
│ [제목 입력] │
└──────────────────────┘
After (세련됨):
┌──────────────────────────┐
│ 🎨 [그라데이션 헤더] │
│ 📌 북마크 추가 ✖ │
├──────────────────────────┤
│ URL │
│ ┌──────────────────┐ │
│ │ 🔗 [입력필드] │ │
│ └──────────────────┘ │
└──────────────────────────┘
주요 개선 사항
1. 그라데이션 헤더
Widget _buildHeader(bool isEditMode) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
decoration: const BoxDecoration(
gradient: AppColors.primaryGradient,
),
child: Row(
children: [
// 아이콘 박스
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isEditMode ? Icons.edit_rounded : Icons.bookmark_add_rounded,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
// 제목 + 부제목
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isEditMode ? '북마크 수정' : '북마크 추가',
style: AppTextStyles.h3.copyWith(
color: Colors.white,
fontSize: 22,
),
),
const SizedBox(height: 4),
Text(
isEditMode ? '북마크 정보 수정' : '새로운 북마크 추가',
style: AppTextStyles.bodySmall.copyWith(
color: Colors.white.withOpacity(0.9),
fontSize: 13,
),
),
],
),
),
// 닫기 버튼
IconButton(
onPressed: () async {
await _animationController.reverse();
Navigator.pop(context);
},
icon: const Icon(Icons.close_rounded, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withOpacity(0.2),
),
),
],
),
);
}
2. 커스텀 텍스트 필드
Widget _buildTextField({
required TextEditingController controller,
required String label,
required String hint,
required IconData icon,
TextInputType? keyboardType,
int maxLines = 1,
String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 레이블
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
label,
style: AppTextStyles.labelMedium.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
// 입력 필드
TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
style: AppTextStyles.bodyMedium,
decoration: InputDecoration(
hintText: hint,
hintStyle: AppTextStyles.bodyMedium.copyWith(
color: AppColors.textTertiary,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 20, color: AppColors.primary),
),
filled: true,
fillColor: AppColors.surfaceVariant,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: AppColors.border.withOpacity(0.5),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.error, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
],
);
}
**입력 필드 특징**:
- ✅ 레이블 분리 (위에 표시)
- ✅ 아이콘 박스 (둥근 배경)
- ✅ 배경색 (surfaceVariant)
- ✅ Focus 시 색상 변경
- ✅ 에러 상태 처리
---
3. 그라데이션 버튼
Widget _buildButtons(BuildContext context) {
return Row(
children: [
// 취소 버튼 (1:1)
Expanded(
child: OutlinedButton(
onPressed: _isLoading
? null
: () async {
await _animationController.reverse();
Navigator.pop(context);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(
color: AppColors.border,
width: 1.5,
),
),
child: Text(
'취소',
style: AppTextStyles.button.copyWith(
color: AppColors.textSecondary,
),
),
),
),
const SizedBox(width: 12),
// 저장 버튼 (1:2)
Expanded(
flex: 2,
child: Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.4),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: ElevatedButton(
onPressed: _isLoading ? null : _handleSave,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.check_rounded,
size: 20,
color: Colors.white,
),
const SizedBox(width: 8),
Text(
'저장',
style: AppTextStyles.button.copyWith(
color: Colors.white,
fontSize: 15,
),
),
],
),
),
),
),
],
);
}
버튼 비율:
[─────취소─────][────────────저장────────────]
33% 67%
4. 페이드 애니메이션
class _AddBookmarkDialogState extends ConsumerState<AddBookmarkDialog>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
// 애니메이션 설정
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
_animationController.forward();
// 나머지 초기화...
}
@override
void dispose() {
_animationController.dispose();
// 나머지...
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: Container(
constraints: const BoxConstraints(maxWidth: 550),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(isEditMode),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTextField(
controller: _urlController,
label: 'URL',
hint: 'https://example.com',
icon: Icons.link_rounded,
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.isEmpty) {
return '필수 입력 항목입니다';
}
if (!value.startsWith('http://') &&
!value.startsWith('https://')) {
return 'http:// 또는 https://로 시작해야 합니다';
}
return null;
},
),
const SizedBox(height: 20),
_buildTextField(
controller: _titleController,
label: '제목',
hint: '북마크 제목을 입력하세요',
icon: Icons.title_rounded,
validator: (value) {
if (value == null || value.isEmpty) {
return '필수 입력 항목입니다';
}
return null;
},
),
const SizedBox(height: 20),
_buildTextField(
controller: _descriptionController,
label: '설명 (선택)',
hint: '북마크에 대한 설명을 입력하세요',
icon: Icons.description_rounded,
maxLines: 3,
),
const SizedBox(height: 20),
CategoryAutocomplete(
initialValue: _selectedCategory,
onChanged: (value) {
setState(() {
_selectedCategory = value;
});
},
),
const SizedBox(height: 32),
_buildButtons(context),
],
),
),
),
),
],
),
),
),
),
);
}
}
애니메이션 효과:
열릴 때:
투명도 0 → 1 (300ms)
easeInOut 곡선
닫힐 때:
투명도 1 → 0 (300ms)
await reverse() 후 Navigator.pop()
📁 5단계: CategoryFilterSection 개선
우측 고정 설정 버튼
Before:
[전체] [General] [개발] [뉴스] [⚙️ 관리]
↑─────────── 전부 스크롤 ─────────────↑
After:
[전체] [General] [개발] [뉴스] ... [⚙️]
↑────────── 스크롤 가능 ─────────↑ 고정
코드
위치:
lib/features/bookmarks/widgets/category_filter_section.dart
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
return categoriesAsync.when(
data: (categories) {
final allCategories = {'General', ...categories}.toList();
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
// 스크롤 가능한 카테고리 칩들
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 16),
child: Row(
children: [
// 전체 버튼
FilterChip(
label: const Text('전체'),
selected: selectedCategory == null,
onSelected: (selected) {
if (selected) {
ref.read(selectedCategoryProvider.notifier).state =
null;
}
},
selectedColor: AppColors.primary,
labelStyle: TextStyle(
color: selectedCategory == null
? Colors.white
: AppColors.textPrimary,
),
),
const SizedBox(width: 8),
// 카테고리 버튼들
...allCategories.map((category) {
final isSelected = selectedCategory == category;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (selected) {
if (selected) {
ref
.read(selectedCategoryProvider.notifier)
.state = category;
} else {
ref
.read(selectedCategoryProvider.notifier)
.state = null;
}
},
selectedColor: AppColors.primary,
labelStyle: TextStyle(
color: isSelected
? Colors.white
: AppColors.textPrimary,
),
),
);
}),
// 스크롤 끝 여백
const SizedBox(width: 8),
],
),
),
),
// 우측 고정 관리 버튼
Container(
margin: const EdgeInsets.only(right: 12, left: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(
Icons.settings_rounded,
size: 22,
),
color: AppColors.textSecondary,
onPressed: () {
showDialog(
context: context,
builder: (context) => const ManageCategoriesDialog(),
);
},
tooltip: '카테고리 관리',
),
),
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
구조:
Row(
children: [
Expanded(
child: SingleChildScrollView(
child: [전체] [카테고리들...],
),
),
Container(설정 버튼), // 고정
],
)
🎯 6단계: 카테고리 기본값 개선
문제점
북마크 추가 시 카테고리가 "General"로 미리 채워져 있음
해결
1. CategoryAutocomplete 수정
@override
void initState() {
super.initState();
// Before: 'General'로 시작
// After: 빈칸으로 시작
_controller = TextEditingController(text: widget.initialValue ?? '');
}
2. AddBookmarkDialog 수정
@override
void initState() {
super.initState();
// 수정 모드가 아니면 빈칸
_selectedCategory = widget.bookmark?.category ?? '';
}
3. 저장 시 처리
Future<void> _handleSave() async {
// ...
// 카테고리가 비어있으면 General로 자동 설정
final category = _selectedCategory.trim().isEmpty
? 'General'
: _selectedCategory.trim();
final newBookmark = Bookmark(
// ...
category: category,
// ...
);
// ...
}
동작:
1. 카테고리 필드 빈칸으로 시작
2. 사용자가 입력 안 하면 → General 자동 저장
3. 입력하면 → 해당 카테고리 저장
4. 수정 모드 → 기존 카테고리 유지
📊 전체 개선 요약
색상
Before | After |
🔵 Material 파란색 | 🟢 에메랄드 그린 |
단색만 사용 | 그라데이션 3종 |
그림자 없음 | 커스텀 그림자 3종 |
타이포그래피
Before | After |
시스템 기본 폰트 | Noto Sans (Google Fonts) |
크기/굵기 불규칙 | 체계적인 스케일 |
letter-spacing 없음 | 최적화된 자간 |
BookmarkCard
Before | After |
elevation: 2 | 커스텀 그림자 |
borderRadius: 12 | borderRadius: 16 |
padding: 16 | padding: 20 |
호버 효과 없음 | 호버 애니메이션 |
아이콘 불규칙 | 모든 카드 아이콘 통일 |
단색 배경 | 그라데이션 배경 |
AddBookmarkDialog
Before | After |
평범한 흰색 헤더 | 그라데이션 헤더 |
기본 TextField | 커스텀 디자인 |
단순 버튼 | 그라데이션 버튼 (1:2) |
애니메이션 없음 | 페이드 애니메이션 |
CategoryFilterSection
Before | After |
관리 버튼 스크롤 | 관리 버튼 우측 고정 |
아이콘 + 텍스트 | 아이콘만 (깔끔) |
🎨 디자인 시스템 체계
간격 시스템
4px → 최소 간격
8px → 작은 간격 (칩 사이, 아이콘 마진)
12px → 중간 간격 (요소 사이)
16px → 기본 간격 (섹션 padding)
20px → 넓은 간격 (카드 padding)
24px → 큰 간격 (다이얼로그 padding)
28px → 매우 큰 간격
둥근 모서리
6px → 작은 요소 (메뉴 아이콘 배경)
8px → 중간 요소 (URL 박스)
10px → 아이콘 배경
12px → 버튼, 입력 필드
14px → TextField
16px → 카드
20px → 뱃지
24px → 다이얼로그
그림자 레벨
softShadow → 기본 카드 (blurRadius: 8)
cardShadow → 강조 카드 (blurRadius: 12)
elevatedShadow → 호버/활성 (blurRadius: 20)
💻 파일 구조
lib/
├── core/
│ └── constants/
│ ├── app_colors.dart ← 새로 추가 (확장)
│ ├── app_text_styles.dart ← 새로 추가
│ └── app_strings.dart
├── features/
│ └── bookmarks/
│ └── widgets/
│ ├── bookmark_card.dart ← 완전 리디자인
│ ├── add_bookmark_dialog.dart ← 완전 리디자인
│ ├── category_filter_section.dart ← 개선
│ └── category_autocomplete.dart ← 개선
└── pubspec.yaml ← google_fonts 추가
🐛 트러블슈팅
문제 1: Google Fonts 로딩 느림
증상: 앱 시작 시 폰트 로딩 지연
해결:
// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 폰트 사전 로드
await GoogleFonts.pendingFonts([
GoogleFonts.notoSans(),
]);
runApp(MyApp());
}
문제 2: 호버 효과가 모바일에서 작동 안 함
원인: MouseRegion은 마우스 전용
해결: 이미 InkWell로 탭 효과 있음
// 모바일: InkWell의 splash 효과
// 웹/데스크톱: MouseRegion의 호버 효과
// 둘 다 적용되어 있음!
문제 3: 다이얼로그 애니메이션 끊김
원인: SingleTickerProviderStateMixin 누락
해결:
class _AddBookmarkDialogState extends ConsumerState<AddBookmarkDialog>
with SingleTickerProviderStateMixin { // ← 필수!
// ...
}
📈 성능 최적화
1. 이미지/폰트 캐싱
// Google Fonts는 자동 캐싱
// 첫 로드 후 로컬 저장
2. 애니메이션 최적화
// AnimatedContainer 사용
// 하드웨어 가속 적용
duration: const Duration(milliseconds: 200), // 200ms (적정)
3. 리스트 렌더링
// ListView.builder 이미 사용 중
// 화면에 보이는 것만 렌더링
🎓 학습 포인트
Material 3 디자인
- 부드러운 그라데이션
- 큰 둥근 모서리 (16px+)
- 풍부한 색상 팔레트
- 의미있는 그림자
- 마이크로 인터랙션
Google Fonts 활용
- 한글 최적화 폰트 선택
- 다양한 weight 활용
- letterSpacing으로 가독성
- height로 줄 간격 조정
애니메이션 원칙
- 200-300ms 권장
- easeInOut 곡선 사용
- 과하지 않게 (subtle)
- 의미있는 피드백
💡 추가 개선 아이디어
1. 다크모드 지원
// app_colors.dart에 추가
class AppColorsDark {
static const Color primary = Color(0xFF34D399);
static const Color background = Color(0xFF1A1A1A);
// ...
}
2. 스켈레톤 로딩
// 로딩 중
Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: BookmarkCardSkeleton(),
)
3. 스와이프 액션
Dismissible(
key: Key(bookmark.id),
background: Container(color: Colors.red),
onDismissed: (direction) {
// 삭제
},
child: BookmarkCard(...),
)
4. 무한 스크롤
// 페이지네이션
final bookmarks = await bookmarkService.getBookmarks(
limit: 20,
startAfter: lastDocument,
);
🔗 참고 자료
공식 문서
디자인 영감
✅ 완성!
구현한 모든 기능
✅ 에메랄드 그린 색상 시스템
✅ Noto Sans 타이포그래피
✅ 호버 애니메이션
✅ 통일된 아이콘 시스템
✅ 그라데이션 헤더/버튼/뱃지
✅ 커스텀 그림자
✅ 페이드 애니메이션
✅ 우측 고정 설정 버튼
✅ 개선된 UX (카테고리 빈칸)
🎉 마치며
이번 편에서는 북마크 앱의 UI를 완전히 새롭게 디자인했습니다!
핵심 내용 요약
- 색상 시스템: 에메랄드 그린 + 그라데이션
- 타이포그래피: Noto Sans로 한글 최적화
- 카드 디자인: 호버 효과 + 통일된 레이아웃
- 다이얼로그: 그라데이션 헤더 + 애니메이션
- UX 개선: 직관적인 인터페이스
배운 점
- Material 3 디자인 원칙
- Google Fonts 활용법
- 마이크로 애니메이션 구현
- 일관된 디자인 시스템 구축
📎 부록
A. 색상 팔레트 전체
Primary Colors:
#10B981 (Emerald Green)
#6EE7B7 (Light Mint)
#059669 (Dark Emerald)
#D1FAE5 (Container)
Secondary Colors:
#8B5CF6 (Violet)
#C4B5FD (Light Violet)
#EDE9FE (Container)
Status Colors:
#EF5350 (Error)
#66BB6A (Success)
#FFA726 (Warning)
#3B82F6 (Info)
SNS Colors:
#FF0000 (YouTube)
#E4405F (Instagram)
#1DA1F2 (Twitter)
#1877F2 (Facebook)
#000000 (TikTok)
B. 타이포그래피 스케일
h1: 32px / Bold
h2: 24px / Bold
h3: 20px / SemiBold
h4: 18px / SemiBold
bodyLarge: 16px / Regular
bodyMedium: 14px / Regular
bodySmall: 12px / Regular
labelLarge: 14px / Medium
labelMedium: 12px / Medium
labelSmall: 11px / Medium
caption: 12px / Regular
button: 14px / SemiBold
link: 14px / Regular (underline)
Share article