275 lines
12 KiB
Dart
275 lines
12 KiB
Dart
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),
|
||
// 시안 행성 상세(373:441117) 탭 — 활성 청록 글자·밑줄, 비활성 #bbbbbb, 트랙 #1a1a1a.
|
||
tabBarTheme: TabBarThemeData(
|
||
labelColor: AppColors.cosmicTeal,
|
||
unselectedLabelColor: const Color(0xFFBBBBBB),
|
||
indicatorColor: AppColors.cosmicTeal,
|
||
indicatorSize: TabBarIndicatorSize.tab,
|
||
dividerColor: const Color(0xFF1A1A1A),
|
||
labelStyle: _styleNoColor(AppType.labelL),
|
||
unselectedLabelStyle: _styleNoColor(AppType.labelL),
|
||
),
|
||
|
||
// ── 아래는 테마를 주지 않으면 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),
|
||
),
|
||
// 선택(흰 바탕)이면 검은 글자 — 상태 색을 주지 않으면 선택 칩도 연회색 글자라 읽히지 않는다(10/6 실기기).
|
||
labelStyle: _style(
|
||
AppType.caption,
|
||
WidgetStateColor.resolveWith(
|
||
(s) => s.contains(WidgetState.selected)
|
||
? Colors.black
|
||
: 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),
|
||
// 「보기」 등 액션 글자 — 기본(inversePrimary)은 어두운 바탕에 묻힌다(10/6 실기기). 강조색 토큰.
|
||
actionTextColor: AppColors.cosmicAccent,
|
||
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)];
|