flutter_ui_kit_theme — 완전 초심자 가이드
Material 3 기반 다중 브랜드 디자인 시스템 패키지.
목차
- 설치
- 기본 셋업
- 색상 시스템
- 텍스트 스타일
- 간격 토큰 AppSpacing
- 모서리 토큰 AppRadius
- 애니메이션 토큰 AppMotion
- 반응형 토큰
- 위젯 사용법
- DsThemeController — 테마 동적 전환
- GlassTheme — 글래스모피즘
1. 설치
pubspec.yaml에 의존성을 추가한다.
dependencies:
flutter_ui_kit_theme: ^0.1.16
그 다음 패키지를 가져온다.
flutter pub get
모든 기능은 파일 하나만 import하면 사용 가능하다.
import 'package:flutter_ui_kit_theme/flutter_ui_kit_theme.dart';
2. 기본 셋업
가장 단순한 사용법 — 테마만 적용
import 'package:flutter/material.dart';
import 'package:flutter_ui_kit_theme/flutter_ui_kit_theme.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: BrandATheme.light(), // 라이트 테마 (Violet 브랜드)
darkTheme: BrandATheme.dark(), // 다크 테마 (Violet 브랜드)
themeMode: ThemeMode.system, // 기기 설정에 따라 자동 전환
home: const MyHomePage(),
);
}
}
브랜드 선택:
| 브랜드 | 라이트 | 다크 | 특징 |
|---|---|---|---|
BrandATheme |
BrandATheme.light() |
BrandATheme.dark() |
Violet(보라) 계열, 기본 모서리 |
BrandBTheme |
BrandBTheme.light() |
BrandBTheme.dark() |
Emerald(에메랄드) 계열, 더 둥근 모서리 |
3. 색상 시스템
핵심 원칙
색상을 직접 AppColors.violet500처럼 쓰지 않는다.
대신 Theme.of(context).colorScheme 을 통해 역할(role) 기반으로 사용한다.
이렇게 하면 다크모드 전환 시 자동으로 올바른 색상으로 바뀐다.
// ❌ 하드코딩 — 다크모드에서 어울리지 않을 수 있음
color: Color(0xFF6C63FF)
// ✅ 역할 기반 — 라이트/다크 자동 전환
color: Theme.of(context).colorScheme.primary
ColorScheme 역할별 색상표
아래 표는 실제 화면에서 각 역할이 어떤 색으로 표시되는지를 정리한 것이다.
BrandA (Violet 브랜드)
| 역할 | 라이트 모드 | 다크 모드 | 사용 용도 |
|---|---|---|---|
primary |
보라 계열 (~#6C63FF) | 밝은 보라 (~#C4BEFF) | 주요 버튼, 강조 요소 |
onPrimary |
흰색 | 진한 보라 | primary 위의 텍스트/아이콘 |
primaryContainer |
연한 보라 | 중간 보라 | 선택된 항목 배경 |
onPrimaryContainer |
진한 보라 | 매우 밝은 보라 | primaryContainer 위의 텍스트 |
secondary |
보라-회색 계열 | 밝은 보라-회색 | 보조 버튼, 2차 요소 |
tertiary |
3차 강조색 | 밝은 3차 강조색 | 3차 요소 |
error |
빨간 계열 | 밝은 빨간 계열 | 에러 상태 |
surface |
#FFFFFF (흰색) |
#0A0E1A (거의 검정) |
기본 배경 |
onSurface |
#0A0E1A (거의 검정) |
#F8F9FB (거의 흰색) |
surface 위의 텍스트 |
onSurfaceVariant |
#3E4860 (진한 회색) |
#D8DCE6 (밝은 회색) |
보조 텍스트, 아이콘 |
outline |
#606B84 (중간 회색) |
#B4BAC8 (밝은 회색) |
테두리, 구분선 |
outlineVariant |
#B4BAC8 (밝은 회색) |
#606B84 (중간 회색) |
약한 테두리 |
inverseSurface |
#0A0E1A |
#F8F9FB |
토스트/스낵바 배경 |
onInverseSurface |
#F8F9FB |
#0A0E1A |
토스트/스낵바 텍스트 |
Surface 계층 (배경 깊이감 표현)
Material 3는 표면을 5단계로 나눈다. 숫자가 올라갈수록 더 밝게(라이트) 또는 더 어두운 배경 위에서 더 높은 층처럼 보인다.
| 역할 | 라이트 모드 | 다크 모드 | 사용 예시 |
|---|---|---|---|
surfaceContainerLowest |
#FFFFFF (흰색) |
#070B14 (가장 어두운 검정) |
최하단 배경 |
surfaceContainerLow |
#F8F9FB (아주 연한 회색) |
#090D18 (매우 어두운 검정) |
앱바 아래 배경 |
surfaceContainer |
#EEF0F4 (연한 회색) |
#0B1220 (어두운 네이비) |
카드 기본 배경 |
surfaceContainerHigh |
#D8DCE6 (회색) |
#0D1F3C (진한 네이비) |
선택된 카드, 모달 |
surfaceContainerHighest |
#D8DCE6 (회색) |
#1A2D4A (중간 네이비) |
입력창, 최상단 레이어 |
BrandB (Emerald 브랜드)
BrandA와 구조는 동일하며, primary 계열만 초록(에메랄드) 계열로 바뀐다. surface/onSurface 등 중립 색상은 동일하다.
| 역할 | 라이트 모드 | 다크 모드 |
|---|---|---|
primary |
에메랄드 (~#00C853) | 밝은 에메랄드 |
onPrimary |
흰색 | 진한 초록 |
실제 코드에서 색상 사용하기
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
color: colors.surface, // 배경색
child: Text(
'안녕하세요',
style: TextStyle(
color: colors.onSurface, // 텍스트 색상 (배경에 대비되는 색)
),
),
);
}
// 버튼 배경 — primary 색상
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
onPressed: () {},
child: const Text('확인'),
)
// 에러 텍스트
Text(
'오류가 발생했습니다',
style: TextStyle(color: Theme.of(context).colorScheme.error),
)
// 구분선
Divider(color: Theme.of(context).colorScheme.outlineVariant)
AppColors — 팔레트 직접 참조 (고급)
일반적으로는 위의 colorScheme을 사용한다. 아래는 특수한 경우에만 사용한다.
// Violet 팔레트 (보라 계열)
AppColors.violet50 // #F0EFFE — 가장 연한 보라 (배경 틴트용)
AppColors.violet100 // #DDD9FD
AppColors.violet200 // #BDB8FB
AppColors.violet300 // #9D97F9
AppColors.violet400 // #8580F9
AppColors.violet500 // #6C63FF ← 브랜드 대표 보라 (가장 많이 쓰임)
AppColors.violet600 // #5B52E8
AppColors.violet700 // #4540C6
AppColors.violet800 // #312EA4
AppColors.violet900 // #1E1B6E
AppColors.violet950 // #120F30 — 가장 진한 보라
// Emerald 팔레트 (초록 계열)
AppColors.emerald50 // #E8FFF2 — 가장 연한 초록
AppColors.emerald100 // #C8FFE0
AppColors.emerald200 // #90FFC0
AppColors.emerald300 // #00E676
AppColors.emerald400 // #00D161
AppColors.emerald500 // #00C853 ← 브랜드 B 대표 초록
AppColors.emerald600 // #00B047
AppColors.emerald700 // #008C38
AppColors.emerald800 // #00692A
AppColors.emerald900 // #004815 — 가장 진한 초록
// Ink 팔레트 (중립/회색 계열)
AppColors.ink0 // #FFFFFF — 순 흰색
AppColors.ink50 // #F8F9FB — 거의 흰색
AppColors.ink100 // #EEF0F4 — 아주 연한 회색
AppColors.ink200 // #D8DCE6 — 연한 회색
AppColors.ink300 // #B4BAC8 — 중간 밝은 회색
AppColors.ink400 // #8890A4 — 중간 회색
AppColors.ink500 // #606B84 — 중간 진한 회색
AppColors.ink600 // #3E4860 — 진한 회색
AppColors.ink700 // #1F2D4A — 매우 진한 회색
AppColors.ink800 // #0D1F3C — 다크 서피스 (다크모드 카드 배경)
AppColors.ink900 // #0A0E1A — 다크 배경 (다크모드 기본 배경)
AppColors.ink950 // #070B14 — 가장 어두운 검정
// 시맨틱 색상 (의미 있는 색상)
AppColors.red300 // 밝은 빨강 (에러 강조)
AppColors.red400 // 중간 빨강
AppColors.red500 // #F44336 — 에러 기본
AppColors.red700 // 진한 빨강
AppColors.red900 // 매우 진한 빨강
AppColors.amber300 // 밝은 노랑 (경고)
AppColors.amber500 // #FF9800 — 경고 기본
AppColors.green300 // 밝은 초록 (성공)
AppColors.green500 // #4CAF50 — 성공 기본
4. 텍스트 스타일(Typography)
기본 원칙
Theme.of(context).textTheme을 통해 스타일을 가져온다.
기본 폰트는 Inter다.
Text(
'안녕하세요',
style: Theme.of(context).textTheme.bodyMedium,
)
전체 타입 스케일
| 이름 | 크기 | 굵기 | 줄 간격 | 사용 용도 |
|---|---|---|---|---|
displayLarge |
57px | w400 (보통) | 1.12 | 히어로 섹션, 랜딩 페이지 대형 문구 |
displayMedium |
45px | w400 | 1.16 | 대형 타이틀 |
displaySmall |
36px | w400 | 1.22 | 섹션 타이틀 |
headlineLarge |
32px | w600 (약간 굵게) | 1.25 | 페이지 제목 |
headlineMedium |
28px | w600 | 1.29 | 카드 헤더 |
headlineSmall |
24px | w600 | 1.33 | 서브 헤더 |
titleLarge |
22px | w600 | 1.27 | 앱바 타이틀, 다이얼로그 제목 |
titleMedium |
16px | w600 | 1.50 | 리스트 아이템 제목 |
titleSmall |
14px | w600 | 1.43 | 작은 섹션 제목 |
bodyLarge |
16px | w400 | 1.50 | 본문 텍스트 (기본) |
bodyMedium |
14px | w400 | 1.43 | 보조 본문 |
bodySmall |
12px | w400 | 1.33 | 캡션, 보조 정보 |
labelLarge |
14px | w500 (중간 굵기) | 1.43 | 버튼 텍스트 |
labelMedium |
12px | w500 | 1.33 | 태그, 칩 텍스트 |
labelSmall |
11px | w500 | 1.45 | 최소 레이블, 배지 |
실제 사용 예시
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 페이지 제목
Text('설정', style: text.headlineLarge),
// 섹션 제목
Text('계정', style: text.titleMedium),
// 설명 텍스트
Text(
'프로필 사진, 이름, 이메일 등을 변경할 수 있습니다.',
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant, // 보조 텍스트는 onSurfaceVariant
),
),
// 작은 보조 정보
Text(
'마지막 수정: 2024-01-01',
style: text.labelSmall?.copyWith(
color: colors.outline,
),
),
],
);
}
텍스트 색상 가이드
| 상황 | 사용 색상 | 코드 |
|---|---|---|
| 기본 본문 텍스트 | onSurface |
colors.onSurface |
| 보조/힌트 텍스트 | onSurfaceVariant |
colors.onSurfaceVariant |
| 비활성/플레이스홀더 | outline |
colors.outline |
| 강조 텍스트 | primary |
colors.primary |
| 에러 메시지 | error |
colors.error |
| primary 배경 위 텍스트 | onPrimary |
colors.onPrimary |
5. 간격 토큰 AppSpacing
8pt 그리드 시스템 기반 간격 상수다. 숫자를 직접 쓰지 말고 이 토큰을 사용한다.
기본 수치
| 토큰 | 픽셀 | 사용 용도 |
|---|---|---|
AppSpacing.x0_5 |
4px | 아이콘과 텍스트 사이 아주 좁은 간격 |
AppSpacing.x1 |
8px | 소형 요소 내부 패딩 |
AppSpacing.x1_5 |
12px | 컴팩트한 패딩 |
AppSpacing.x2 |
16px | 기본 패딩 (가장 많이 씀) |
AppSpacing.x3 |
24px | 섹션 간격 |
AppSpacing.x4 |
32px | 큰 섹션 간격 |
AppSpacing.x5 |
40px | 페이지 레벨 간격 |
AppSpacing.x6 |
48px | 최대 간격 |
EdgeInsets 프리셋
| 토큰 | 값 | 사용 용도 |
|---|---|---|
AppSpacing.screenPadding |
수평 16px | 화면 좌우 여백 |
AppSpacing.pagePadding |
상하 24px, 좌우 16px | 페이지 전체 패딩 |
AppSpacing.cardPadding |
전체 16px | 카드 내부 패딩 |
AppSpacing.compactPadding |
전체 8px | 작은 카드/칩 패딩 |
AppSpacing.buttonPadding |
상하 12px, 좌우 24px | 커스텀 버튼 패딩 |
AppSpacing.inputPadding |
상하 12px, 좌우 16px | 입력창 내부 패딩 |
// ❌ 직접 숫자 입력
Padding(padding: EdgeInsets.all(16), ...)
// ✅ 토큰 사용
Padding(padding: AppSpacing.cardPadding, ...)
SizedBox Gap 헬퍼
간격을 만들 때 SizedBox를 직접 만들지 않아도 된다.
Column(
children: [
Text('제목'),
AppSpacing.gapV2, // 세로 16px 간격
Text('내용'),
AppSpacing.gapV1, // 세로 8px 간격
Text('부연 설명'),
],
)
Row(
children: [
Icon(Icons.star),
AppSpacing.gapH1, // 가로 8px 간격
Text('별점'),
],
)
| 토큰 | 방향 | 크기 |
|---|---|---|
AppSpacing.gap1 |
양방향(정사각) | 8px |
AppSpacing.gap2 |
양방향 | 16px |
AppSpacing.gap3 |
양방향 | 24px |
AppSpacing.gap4 |
양방향 | 32px |
AppSpacing.gapH1 |
가로 | 8px |
AppSpacing.gapH2 |
가로 | 16px |
AppSpacing.gapH3 |
가로 | 24px |
AppSpacing.gapH4 |
가로 | 32px |
AppSpacing.gapV1 |
세로 | 8px |
AppSpacing.gapV2 |
세로 | 16px |
AppSpacing.gapV3 |
세로 | 24px |
AppSpacing.gapV4 |
세로 | 32px |
AppSpacing.gapV5 |
세로 | 40px |
AppSpacing.gapV6 |
세로 | 48px |
6. 모서리 토큰 AppRadius
둥근 모서리 반경 상수다.
기본 수치
| 토큰 | 픽셀 | 모양 |
|---|---|---|
AppRadius.none |
0px | 직각 |
AppRadius.xs |
4px | 아주 살짝 둥글게 |
AppRadius.sm |
8px | 약간 둥글게 |
AppRadius.md |
12px | 보통 둥글게 |
AppRadius.lg |
16px | 많이 둥글게 |
AppRadius.xl |
24px | 매우 둥글게 |
AppRadius.xxl |
32px | 극도로 둥글게 |
AppRadius.full |
999px | 완전 원형 (캡슐 모양) |
시맨틱 상수 (UI 요소별 권장값)
| 토큰 | 값 | 적용 요소 |
|---|---|---|
AppRadius.card |
lg (16px) |
카드 컴포넌트 |
AppRadius.button |
md (12px) |
버튼 |
AppRadius.input |
md (12px) |
입력창 |
AppRadius.chip |
full (999px) |
태그, 필터 칩 |
AppRadius.dialog |
xl (24px) |
다이얼로그, 모달 |
AppRadius.snackBar |
sm (8px) |
스낵바, 토스트 |
AppRadius.avatar |
full (999px) |
프로필 아바타 |
AppRadius.bottomSheet |
상단만 xl (24px) |
바텀시트 |
// 카드
Container(
decoration: BoxDecoration(
borderRadius: AppRadius.card, // BorderRadius.circular(16)와 동일
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: ...,
)
// 버튼
ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: AppRadius.button),
),
onPressed: () {},
child: const Text('확인'),
)
// 완전 원형 아바타
ClipRRect(
borderRadius: AppRadius.avatar,
child: Image.network('https://...'),
)
7. 애니메이션 토큰 AppMotion
Duration (지속 시간)
| 토큰 | 값 | 사용 용도 |
|---|---|---|
AppMotion.instant |
0ms | 즉시 (애니메이션 없음) |
AppMotion.micro |
100ms | 아이콘 상태 변화 등 매우 빠른 피드백 |
AppMotion.fast |
150ms | 호버, 포커스 등 빠른 인터랙션 |
AppMotion.moderate |
250ms | 대부분의 UI 트랜지션 기본값 |
AppMotion.standard |
300ms | 모달, 드로어 등 패널 등장 |
AppMotion.deliberate |
400ms | 페이지 전환 |
AppMotion.slow |
500ms | 강조용 애니메이션 |
AppMotion.page |
350ms | 페이지 전환 전용 |
Curve (가속 곡선)
| 토큰 | Flutter 기본값 | 사용 용도 |
|---|---|---|
AppMotion.emphasized |
Curves.easeInOutCubicEmphasized |
M3 강조 전환 (권장) |
AppMotion.emphasizedDecel |
Curves.easeOutCubic |
요소가 화면에 들어올 때 |
AppMotion.emphasizedAccel |
Curves.easeInCubic |
요소가 화면에서 나갈 때 |
AppMotion.spring |
Curves.elasticOut |
탄성 있는 효과 |
AppMotion.linear |
Curves.linear |
일정한 속도 |
AppMotion.easeIn |
Curves.easeIn |
천천히 시작 |
AppMotion.easeOut |
Curves.easeOut |
천천히 끝남 |
AppMotion.easeInOut |
Curves.easeInOut |
양쪽 부드럽게 |
사용 예시
// AnimatedContainer
AnimatedContainer(
duration: AppMotion.standard,
curve: AppMotion.emphasized,
width: isExpanded ? 200 : 100,
color: Theme.of(context).colorScheme.primaryContainer,
)
// AnimatedOpacity
AnimatedOpacity(
duration: AppMotion.fast,
opacity: isVisible ? 1.0 : 0.0,
child: const Text('안녕하세요'),
)
// PageRouteBuilder
Navigator.push(context, PageRouteBuilder(
transitionDuration: AppMotion.page,
pageBuilder: (_, __, ___) => const NextPage(),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: AppMotion.emphasizedDecel,
),
child: child,
);
},
));
8. 반응형 토큰
AppBreakpoints
| 토큰 | 값 | 의미 |
|---|---|---|
AppBreakpoints.wide |
600px | 넓은 폰 / 소형 태블릿 |
AppBreakpoints.tablet |
768px | 태블릿 기준 |
// 현재 기기가 태블릿인지 확인
final isTablet = AppBreakpoints.isTablet(MediaQuery.of(context).size.width);
// 조건부 레이아웃
Widget build(BuildContext context) {
final isTablet = AppBreakpoints.isTablet(MediaQuery.of(context).size.width);
return isTablet
? const TwoColumnLayout()
: const SingleColumnLayout();
}
AppScale
기기 크기에 따라 자동으로 적절한 값을 반환한다.
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isTablet = AppBreakpoints.isTablet(width);
return Padding(
padding: EdgeInsets.all(AppScale.sectionPadding(isTablet)),
// isTablet=true → 32px, isTablet=false → 16px
child: Column(
children: [
SizedBox(height: AppScale.gap(isTablet)),
// isTablet=true → 24px, isTablet=false → 12px
],
),
);
}
| 메서드 | 폰 | 태블릿 |
|---|---|---|
AppScale.sectionPadding(isTablet) |
16px | 32px |
AppScale.gap(isTablet) |
12px | 24px |
AppScale.chipSpacing(isTablet) |
8px | 16px |
9. 위젯 사용법
DsButton — 버튼
3가지 변형(filled/outlined/ghost)을 지원하는 버튼 컴포넌트다.
// 기본 (filled — 채워진 배경)
DsButton(
label: '확인',
onPressed: () {},
)
// Outlined — 테두리만 있는 버튼
DsButton(
label: '취소',
onPressed: () {},
variant: DsButtonVariant.outlined,
)
// Ghost — 배경/테두리 없는 텍스트 버튼
DsButton(
label: '더 보기',
onPressed: () {},
variant: DsButtonVariant.ghost,
)
// 로딩 상태
DsButton(
label: '저장 중',
onPressed: null, // null이면 비활성화
loading: true,
)
// 전체 너비
DsButton(
label: '로그인',
onPressed: () {},
expanded: true,
)
// 아이콘 포함
DsButton(
label: '업로드',
onPressed: () {},
icon: Icons.upload,
)
DsSurface — 배경 컨테이너
Material 3의 surface 계층을 쉽게 사용한다.
level이 높을수록 더 높이 떠 있는 것처럼 보인다(라이트에서는 더 어두운 회색, 다크에서는 더 밝은 네이비).
// 기본 (base 레벨)
DsSurface(
child: Padding(
padding: AppSpacing.cardPadding,
child: Text('내용'),
),
)
// 레벨별 예시
DsSurface(level: SurfaceLevel.lowest, child: ...) // 최하단 배경
DsSurface(level: SurfaceLevel.low, child: ...) // 낮은 배경
DsSurface(level: SurfaceLevel.base, child: ...) // 기본 배경 (기본값)
DsSurface(level: SurfaceLevel.high, child: ...) // 높은 배경 (카드)
DsSurface(level: SurfaceLevel.highest, child: ...) // 최상단 (입력창, 모달)
라이트/다크 모드에서의 색상 변화:
| Level | 라이트 | 다크 |
|---|---|---|
lowest |
#FFFFFF (흰색) | #070B14 (거의 검정) |
low |
#F8F9FB (아주 연한 회색) | #090D18 (매우 어두운 검정) |
base |
#EEF0F4 (연한 회색) | #0B1220 (어두운 네이비) |
high |
#D8DCE6 (회색) | #0D1F3C (진한 네이비) |
highest |
#D8DCE6 (회색) | #1A2D4A (중간 네이비) |
DsCard — 탭 가능한 카드
DsSurface + InkWell의 조합. 탭할 수 있는 카드를 만들 때 사용한다.
DsCard(
onTap: () => print('카드 탭됨'),
child: Padding(
padding: AppSpacing.cardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'카드 제목',
style: Theme.of(context).textTheme.titleMedium,
),
AppSpacing.gapV1,
Text(
'카드 내용 설명입니다.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
AppSpacing.gapV2,
DsButton(label: '자세히 보기', onPressed: () {}),
],
),
),
)
// 레벨 지정
DsCard(
level: SurfaceLevel.high,
onTap: () {},
child: ...,
)
DsThemeToggle — 테마 전환 버튼
Light → Dark → System 순환하는 아이콘 버튼이다.
AppBar(
actions: [
DsThemeToggle(
mode: _themeMode,
onChanged: (mode) => setState(() => _themeMode = mode),
),
],
)
DsBrandToggle — 브랜드 전환 버튼
Violet ↔ Emerald 전환 버튼이다.
AppBar(
actions: [
DsBrandToggle(
brand: _brand,
onChanged: (brand) => setState(() => _brand = brand),
),
],
)
DsBrand enum:
DsBrand.violet // Violet 브랜드 → BrandATheme 사용
DsBrand.emerald // Emerald 브랜드 → BrandBTheme 사용
// enum에서 직접 테마 생성
_brand.lightTheme() // BrandATheme.light() 또는 BrandBTheme.light()
_brand.darkTheme() // BrandATheme.dark() 또는 BrandBTheme.dark()
10. DsThemeController — 테마 동적 전환
앱 재시작 후에도 사용자의 테마 선택을 유지하고 싶을 때 사용한다. SharedPreferences를 내부적으로 사용해 자동 저장/불러오기를 처리한다.
완전한 셋업 예시
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_ui_kit_theme/flutter_ui_kit_theme.dart';
void main() async {
// SharedPreferences 초기화를 위해 반드시 필요
WidgetsFlutterBinding.ensureInitialized();
final controller = DsThemeController();
await controller.init(); // 저장된 설정 불러오기
runApp(MyApp(controller: controller));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.controller});
final DsThemeController controller;
@override
Widget build(BuildContext context) {
return DsThemeBuilder(
controller: controller,
builder: (ctrl, child) {
return MaterialApp(
theme: ctrl.brand.lightTheme(), // 현재 브랜드의 라이트 테마
darkTheme: ctrl.brand.darkTheme(), // 현재 브랜드의 다크 테마
themeMode: ctrl.themeMode, // light / dark / system
locale: ctrl.locale, // 저장된 로케일
home: child,
);
},
child: const HomePage(),
);
}
}
설정 변경하기
// 위젯에서 controller를 받아서 사용
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key, required this.controller});
final DsThemeController controller;
@override
Widget build(BuildContext context) {
return Column(
children: [
// 테마 모드 변경
DsThemeToggle(
mode: controller.themeMode,
onChanged: controller.setThemeMode, // 자동 저장됨
),
// 브랜드 변경
DsBrandToggle(
brand: controller.brand,
onChanged: controller.setBrand, // 자동 저장됨
),
],
);
}
}
DsThemeController 주요 속성 및 메서드
// 현재 상태 읽기
controller.themeMode // ThemeMode (light / dark / system)
controller.brand // DsBrand (violet / emerald)
controller.locale // Locale? (null이면 기기 기본값)
// 상태 변경 (자동으로 SharedPreferences에 저장됨)
await controller.setThemeMode(ThemeMode.dark);
await controller.setBrand(DsBrand.emerald);
await controller.setLocale(const Locale('ko', 'KR'));
await controller.clearLocale(); // 로케일 초기화
11. GlassTheme — 글래스모피즘
반투명 유리 효과를 가진 UI를 만들 때 사용하는 ThemeExtension이다.
테마에 등록하기
BrandATheme이나 BrandBTheme에는 기본적으로 포함되어 있다.
커스텀 테마를 만들 경우 직접 등록해야 한다.
ThemeData(
extensions: [
GlassTheme.dark(), // 다크 테마용
// GlassTheme.light(), // 라이트 테마용
],
)
GlassTheme 기본값
| 속성 | 다크 모드 | 라이트 모드 |
|---|---|---|
blurSigma |
15.0 | 10.0 |
borderOpacity |
0.2 (흰색 20%) | 0.15 (검정 15%) |
surfaceOpacity |
0.07 (흰색 7%) | 0.12 (검정 12%) |
borderColor |
Colors.white |
Colors.black |
surfaceColor |
Colors.white |
Colors.black |
글래스 컨테이너 직접 만들기
import 'dart:ui';
class GlassContainer extends StatelessWidget {
const GlassContainer({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final glass = GlassTheme.resolve(context); // 현재 테마의 GlassTheme 가져오기
return ClipRRect(
borderRadius: AppRadius.card,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: glass.blurSigma,
sigmaY: glass.blurSigma,
),
child: Container(
decoration: BoxDecoration(
color: glass.effectiveSurfaceColor, // surfaceColor에 opacity 적용된 색상
borderRadius: AppRadius.card,
border: Border.all(
color: glass.effectiveBorderColor, // borderColor에 opacity 적용된 색상
width: 1.0,
),
),
child: child,
),
),
);
}
}
전체 예제 앱
import 'package:flutter/material.dart';
import 'package:flutter_ui_kit_theme/flutter_ui_kit_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final ctrl = DsThemeController();
await ctrl.init();
runApp(MyApp(controller: ctrl));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.controller});
final DsThemeController controller;
@override
Widget build(BuildContext context) {
return DsThemeBuilder(
controller: controller,
builder: (ctrl, child) => MaterialApp(
theme: ctrl.brand.lightTheme(),
darkTheme: ctrl.brand.darkTheme(),
themeMode: ctrl.themeMode,
home: child,
),
child: HomePage(controller: controller),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key, required this.controller});
final DsThemeController controller;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(
title: Text('디자인 시스템', style: text.titleLarge),
actions: [
DsBrandToggle(
brand: controller.brand,
onChanged: controller.setBrand,
),
DsThemeToggle(
mode: controller.themeMode,
onChanged: controller.setThemeMode,
),
],
),
body: Padding(
padding: AppSpacing.pagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('색상 팔레트', style: text.headlineMedium),
AppSpacing.gapV2,
// 색상 미리보기
Row(
children: [
_ColorChip(color: colors.primary, label: 'primary'),
AppSpacing.gapH1,
_ColorChip(color: colors.secondary, label: 'secondary'),
AppSpacing.gapH1,
_ColorChip(color: colors.tertiary, label: 'tertiary'),
],
),
AppSpacing.gapV3,
Text('버튼 변형', style: text.headlineMedium),
AppSpacing.gapV2,
Row(
children: [
DsButton(label: 'Filled', onPressed: () {}),
AppSpacing.gapH1,
DsButton(label: 'Outlined', onPressed: () {}, variant: DsButtonVariant.outlined),
AppSpacing.gapH1,
DsButton(label: 'Ghost', onPressed: () {}, variant: DsButtonVariant.ghost),
],
),
AppSpacing.gapV3,
Text('카드 레벨', style: text.headlineMedium),
AppSpacing.gapV2,
DsCard(
level: SurfaceLevel.high,
onTap: () {},
child: Padding(
padding: AppSpacing.cardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('카드 제목', style: text.titleMedium),
AppSpacing.gapV1,
Text(
'카드 내용이 여기에 들어갑니다.',
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
),
],
),
),
);
}
}
class _ColorChip extends StatelessWidget {
const _ColorChip({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
borderRadius: AppRadius.sm,
),
),
AppSpacing.gapV0_5,
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}
자주 하는 실수
1. 색상을 직접 하드코딩
// ❌ 다크모드에서 안 보일 수 있음
Text('안녕', style: TextStyle(color: Colors.black))
// ✅ 자동 전환
Text('안녕', style: TextStyle(color: Theme.of(context).colorScheme.onSurface))
2. main()에서 init() 빠뜨리기
// ❌ 저장된 설정이 불러와지지 않음
void main() {
final ctrl = DsThemeController();
runApp(MyApp(controller: ctrl));
}
// ✅
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final ctrl = DsThemeController();
await ctrl.init();
runApp(MyApp(controller: ctrl));
}
3. DsThemeBuilder 없이 DsThemeController 사용
// ❌ 컨트롤러가 변경되어도 UI가 업데이트되지 않음
MaterialApp(
theme: controller.brand.lightTheme(),
...
)
// ✅ DsThemeBuilder로 감싸면 자동 리빌드
DsThemeBuilder(
controller: controller,
builder: (ctrl, child) => MaterialApp(
theme: ctrl.brand.lightTheme(),
...
),
)
Libraries
- brand/brand_a_theme
- brand/brand_b_theme
- controller/ds_theme_controller
- design_system
- Flutter UI Kit — Material 3 Design System
- flutter_ui_kit_theme
- Public entry point for
package:flutter_ui_kit_theme. - glass/glass_theme
- theme/app_color_schemes
- theme/base_theme
- theme/dark_theme
- theme/ds_app_palette
- theme/light_theme
- tokens/app_breakpoints
- tokens/app_colors
- tokens/app_motion
- tokens/app_radius
- tokens/app_scale
- tokens/app_spacing
- tokens/app_typography
- widgets/ds_app
- widgets/ds_brand_toggle
- widgets/ds_card
- widgets/ds_surface
- widgets/ds_theme_toggle