flutter_ui_kit_theme — 완전 초심자 가이드

Material 3 기반 다중 브랜드 디자인 시스템 패키지.


목차

  1. 설치
  2. 기본 셋업
  3. 색상 시스템
  4. 텍스트 스타일
  5. 간격 토큰 AppSpacing
  6. 모서리 토큰 AppRadius
  7. 애니메이션 토큰 AppMotion
  8. 반응형 토큰
  9. 위젯 사용법
  10. DsThemeController — 테마 동적 전환
  11. 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(),
    ...
  ),
)