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

218 lines
9.2 KiB
Dart

import 'package:flutter/material.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,
scaffoldBackgroundColor: AppColors.cosmicVoid,
textTheme: _textTheme(scheme),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.cosmicVoid,
surfaceTintColor: Colors.transparent,
centerTitle: true,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.cosmicSurface,
surfaceTintColor: Colors.transparent,
indicatorColor: AppColors.cosmicSurfaceRaised,
// 탭 항목 높이는 touch-min-app(48) 아래로 내리지 않는다.
height: AppSizing.touchMinApp + AppSpace.s5,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => _style(
AppType.caption,
states.contains(WidgetState.selected)
? AppColors.cosmicAccent
: AppColors.onCosmicVariant,
),
),
),
dividerTheme: const DividerThemeData(color: AppColors.cosmicOutline, thickness: 1),
// ── 아래는 테마를 주지 않으면 Material 3 기본값(보라 시드)으로 칠해지는 것들이다.
// 코스믹 다크 위에서 눈에 띄게 어긋나므로 전부 토큰으로 고정한다.
chipTheme: ChipThemeData(
backgroundColor: AppColors.cosmicSurfaceContainer,
selectedColor: AppColors.cosmicSurfaceRaised,
// 선택 상태는 색면이 아니라 테두리와 글자색으로 알린다 — 악센트는 행동에만 쓴다.
side: const BorderSide(color: AppColors.cosmicOutline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
labelStyle: _style(AppType.labelL, AppColors.onCosmicVariant),
secondaryLabelStyle: _style(AppType.labelL, AppColors.cosmicAccent),
checkmarkColor: AppColors.cosmicAccent,
showCheckmark: false,
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s3,
vertical: AppSpace.s2,
),
),
cardTheme: CardThemeData(
color: AppColors.cosmicSurfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
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,
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: AppColors.cosmicSurfaceContainer,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.cosmicSurfaceContainer,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
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(AppSizing.touchMinApp),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
textStyle: _styleNoColor(AppType.titleM),
),
),
iconTheme: const IconThemeData(color: AppColors.onCosmicVariant),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
// 글자색은 여기서 정하지 않는다 — foregroundColor 가 결정한다.
// textStyle 에 색을 넣으면 소셜 버튼처럼 제공자별 색을 쓰는 곳과 지정이 두 곳으로 갈린다.
textStyle: _styleNoColor(AppType.titleM),
),
),
);
}
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),
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,
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)];