Files
fanuzu/app/lib/core/theme/app_theme.dart

255 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../ui/app_icons.dart';
import 'tokens.dart';
/// design-tokens.md → ThemeData 조립.
///
/// 값은 전부 [tokens.dart] 에서 온다. 여기서 색·크기를 새로 만들면 시안과 어긋난다.
/// 서피스는 코스믹 보이드(순수 검정)다 — 결정 D-19, `design/아트디렉션-예외.md`.
///
/// 컬러 운용은 60-30-10 이고 악센트는 행동·선택·포커스에만 쓴다.
/// ColorScheme 슬롯에 네온을 꽂아 뒀다고 해서 화면에서 네 색을 다 풀면 안 된다 —
/// 화면당 네온은 하나를 주로 하고 나머지는 보조로만(design-tokens.md 「컬러 운용 규율」).
ThemeData buildAppTheme() {
const scheme = ColorScheme.dark(
primary: AppColors.cosmicAccent,
onPrimary: AppColors.onCosmicAccent,
secondary: AppColors.neonViolet,
onSecondary: AppColors.onNeonViolet,
// 컨테이너 슬롯을 비워 두면 Material 이 기본 시드(보라)에서 값을 만들어 낸다.
// ChoiceChip 의 선택 상태가 그 색을 쓰므로 코스믹 다크 위에 남색 칩이 뜬다.
primaryContainer: AppColors.cosmicSurfaceRaised,
onPrimaryContainer: AppColors.cosmicAccent,
secondaryContainer: AppColors.cosmicSurfaceRaised,
onSecondaryContainer: AppColors.cosmicAccent,
tertiaryContainer: AppColors.cosmicSurfaceRaised,
onTertiaryContainer: AppColors.neonCyan,
tertiary: AppColors.neonCyan,
onTertiary: AppColors.onNeonCyan,
error: AppColors.error,
onError: AppColors.onError,
surface: AppColors.cosmicVoid,
onSurface: AppColors.onCosmic,
onSurfaceVariant: AppColors.onCosmicVariant,
surfaceContainerLowest: AppColors.cosmicVoid,
surfaceContainerLow: AppColors.cosmicSurface,
surfaceContainer: AppColors.cosmicSurfaceContainer,
surfaceContainerHigh: AppColors.cosmicSurfaceRaised,
surfaceContainerHighest: AppColors.cosmicSurfaceRaised,
outline: AppColors.cosmicOutline,
outlineVariant: AppColors.cosmicOutline,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
// 글꼴을 지정하지 않은 글자도 Figma 본문 글꼴(42dot Sans)로 그린다.
fontFamily: AppFonts.body,
// Figma Iconset(Lucide) — 뒤로가기 chevron-left · 닫기 x.
actionIconTheme: ActionIconThemeData(
backButtonIconBuilder: (_) => const ImageIcon(AppIcons.chevronLeft, size: 24),
closeButtonIconBuilder: (_) => const ImageIcon(AppIcons.x, size: 24),
),
scaffoldBackgroundColor: AppColors.cosmicVoid,
textTheme: _textTheme(scheme),
// Figma Top Navigation(알림 229:11265 · 설정 354:222008 등) — 높이 56, 바탕 #141414,
// 제목 42dot Sans 16/600 가운데, 아이콘 24 를 56×56 칸에.
appBarTheme: AppBarTheme(
backgroundColor: AppColors.cosmicVoid,
foregroundColor: AppColors.onCosmic,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
toolbarHeight: 56,
titleTextStyle: _style(AppType.navTitle, AppColors.onCosmic),
iconTheme: const IconThemeData(color: AppColors.onCosmic, size: 24),
actionsIconTheme: const IconThemeData(color: AppColors.onCosmic, size: 24),
),
// 시안 Bottom Navigation(350:215853) — 바탕 #141414, 알약 표시자 없음, 아이콘 24,
// 라벨 ABeeZee 10/16. 활성은 흰색, 비활성은 #777777. 탭 칸 높이 64(홈 인디케이터 34 별도).
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.cosmicVoid,
surfaceTintColor: Colors.transparent,
indicatorColor: Colors.transparent,
height: 64,
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
size: 24,
color: states.contains(WidgetState.selected)
? AppColors.onCosmic
: AppColors.onCosmicMuted,
),
),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => _style(
AppType.navLabel,
states.contains(WidgetState.selected)
? AppColors.onCosmic
: AppColors.onCosmicMuted,
),
),
),
dividerTheme: const DividerThemeData(color: AppColors.cosmicOutline, thickness: 1),
// ── 아래는 테마를 주지 않으면 Material 3 기본값(보라 시드)으로 칠해지는 것들이다.
// 코스믹 다크 위에서 눈에 띄게 어긋나므로 전부 토큰으로 고정한다.
// Figma 정렬 칩(Inside_최신순 196:2673) — 바탕 #1a1a1c · 테두리 #232324 · 모서리 8 ·
// 안쪽 6/10 · 글자 12/500. 선택은 흰 바탕에 검은 글자다.
chipTheme: ChipThemeData(
backgroundColor: AppColors.cosmicSurfaceContainer,
selectedColor: AppColors.onCosmic,
side: const BorderSide(color: AppColors.cosmicSurfaceRaised),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
labelStyle: _style(AppType.caption, const Color(0xFFD5D7D9)),
secondaryLabelStyle: _style(AppType.caption, AppColors.onCosmicAccent),
checkmarkColor: AppColors.onCosmicAccent,
showCheckmark: false,
labelPadding: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
cardTheme: CardThemeData(
color: AppColors.cosmicSurfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 0,
margin: EdgeInsets.zero,
// Figma 카드(MY 373:632962 등 36곳) — 모서리 16.
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
elevation: 0,
extendedTextStyle: _styleNoColor(AppType.titleM),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
inputDecorationTheme: InputDecorationTheme(
// 채워진 상자 대신 밑줄을 쓴다. 어두운 서피스에서 상자를 겹치면 층이 흐려진다.
labelStyle: _style(AppType.bodyM, AppColors.onCosmicVariant),
floatingLabelStyle: _style(AppType.caption, AppColors.cosmicAccent),
helperStyle: _style(AppType.caption, AppColors.onCosmicVariant),
counterStyle: _style(AppType.caption, AppColors.onCosmicVariant),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.cosmicOutline),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.cosmicAccent, width: 2),
),
errorBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.error),
),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.cosmicAccent,
linearTrackColor: AppColors.cosmicSurfaceRaised,
circularTrackColor: Colors.transparent,
),
// Figma 액션 시트(343:212939) #2C2C2E · 모서리 16.
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: AppColors.cosmicSheet,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
),
// Figma 확인 팝업(차단하기 373:442417) #2D2D2D · 모서리 12.
dialogTheme: DialogThemeData(
backgroundColor: AppColors.cosmicDialog,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.cosmicSurfaceRaised,
contentTextStyle: _style(AppType.bodyM, AppColors.onCosmic),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
listTileTheme: ListTileThemeData(
iconColor: AppColors.onCosmicVariant,
titleTextStyle: _style(AppType.bodyL, AppColors.onCosmic),
subtitleTextStyle: _style(AppType.bodyS, AppColors.onCosmicVariant),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.cosmicAccent,
textStyle: _styleNoColor(AppType.titleM),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.onCosmic,
side: const BorderSide(color: AppColors.cosmicOutline),
minimumSize: const Size.fromHeight(_buttonHeight),
padding: _buttonPadding,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
textStyle: _styleNoColor(AppType.navTitle),
),
),
iconTheme: const IconThemeData(color: AppColors.onCosmicVariant),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(_buttonHeight),
padding: _buttonPadding,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
// 글자색은 여기서 정하지 않는다 — foregroundColor 가 결정한다.
// textStyle 에 색을 넣으면 소셜 버튼처럼 제공자별 색을 쓰는 곳과 지정이 두 곳으로 갈린다.
textStyle: _styleNoColor(AppType.navTitle),
),
),
);
}
/// Figma 주 버튼(퀘스트 상세 264:48835 등) — 343×56, 모서리 8, 안쪽 16/24, 글자 16/600.
const _buttonHeight = 56.0;
const _buttonPadding = EdgeInsets.symmetric(horizontal: 24, vertical: 16);
TextTheme _textTheme(ColorScheme scheme) => TextTheme(
displayLarge: _style(AppType.displayL, scheme.onSurface),
displayMedium: _style(AppType.displayM, scheme.onSurface),
headlineSmall: _style(AppType.titleL, scheme.onSurface),
titleLarge: _style(AppType.titleL, scheme.onSurface),
titleMedium: _style(AppType.titleM, scheme.onSurface),
titleSmall: _style(AppType.labelL, scheme.onSurface),
bodyLarge: _style(AppType.bodyL, scheme.onSurface),
bodyMedium: _style(AppType.bodyM, scheme.onSurface),
bodySmall: _style(AppType.bodyS, scheme.onSurfaceVariant),
labelLarge: _style(AppType.labelL, scheme.onSurface),
labelMedium: _style(AppType.eyebrow, scheme.onSurfaceVariant),
labelSmall: _style(AppType.caption, scheme.onSurfaceVariant),
);
/// [TypeToken] → [TextStyle].
///
/// 폰트 파일 미투입 상태에서는 fontFamily 가 매칭되지 않아 시스템 폰트로 폴백된다.
/// 크기·굵기·행간·자간은 그대로 적용되므로 레이아웃은 투입 전후가 거의 같다.
TextStyle _style(TypeToken t, Color color) => _styleNoColor(t).copyWith(color: color);
/// 색 없는 타입 스타일. 색을 다른 속성(foregroundColor 등)이 정하는 자리에 쓴다.
TextStyle _styleNoColor(TypeToken t) => TextStyle(
fontFamily: t.family,
// ABeeZee 는 라틴 전용이다. 한글은 본문 글꼴로 이어 그린다(Figma 도 같은 대체를 한다).
fontFamilyFallback: const [AppFonts.body],
fontSize: t.size,
fontWeight: _weight(t.weight),
height: t.height,
letterSpacing: t.letterSpacing,
);
FontWeight _weight(int value) =>
FontWeight.values[(value ~/ 100 - 1).clamp(0, FontWeight.values.length - 1)];