[Flutter + Firebase로 북마크 앱 만들기 #9] 모던 UI 디자인 시스템 구축

도경원's avatar
Oct 16, 2025
[Flutter + Firebase로 북마크 앱 만들기 #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를 완전히 새롭게 디자인했습니다!

핵심 내용 요약

  1. 색상 시스템: 에메랄드 그린 + 그라데이션
  1. 타이포그래피: Noto Sans로 한글 최적화
  1. 카드 디자인: 호버 효과 + 통일된 레이아웃
  1. 다이얼로그: 그라데이션 헤더 + 애니메이션
  1. 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

Gyeongwon's blog