diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index f2ceb66..0f36e8b 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -16,6 +16,14 @@ ThemeData buildAppTheme() { 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, @@ -58,6 +66,110 @@ ThemeData buildAppTheme() { ), ), 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), diff --git a/app/test/theme_coverage_test.dart b/app/test/theme_coverage_test.dart new file mode 100644 index 0000000..1c3b46c --- /dev/null +++ b/app/test/theme_coverage_test.dart @@ -0,0 +1,68 @@ +import 'package:fancake/core/theme/app_theme.dart'; +import 'package:fancake/core/theme/tokens.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 테마를 주지 않은 컴포넌트는 Material 3 가 기본 시드(보라)에서 색을 만들어 낸다. +/// +/// 코스믹 다크 서피스 위에 그 색이 뜨면 시안과 어긋나는데, 화면을 눈으로 보기 +/// 전까지 드러나지 않는다. 실제로 INSIDE·STATION 정렬 칩이 보라색으로 나간 적이 +/// 있어(09-02) 그 회귀를 여기서 잡는다. +void main() { + final theme = buildAppTheme(); + + /// 코스믹 팔레트에 속한 색인가. 토큰 밖의 색이면 Material 이 만들어 낸 것이다. + bool isCosmic(Color? c) { + if (c == null) return false; + final palette = { + AppColors.cosmicVoid, + AppColors.cosmicSurface, + AppColors.cosmicSurfaceContainer, + AppColors.cosmicSurfaceRaised, + AppColors.cosmicOutline, + AppColors.cosmicAccent, + AppColors.onCosmic, + AppColors.onCosmicVariant, + AppColors.onCosmicAccent, + AppColors.neonCyan, + AppColors.neonViolet, + AppColors.error, + }; + return palette.contains(c) || c == Colors.transparent; + } + + group('테마가 컴포넌트를 비워 두지 않는다', () { + test('Material 기본값으로 떨어지던 12종에 테마가 있다', () { + expect(theme.chipTheme.backgroundColor, isNotNull, reason: 'ChipTheme'); + expect(theme.cardTheme.color, isNotNull, reason: 'CardTheme'); + expect(theme.floatingActionButtonTheme.backgroundColor, isNotNull, reason: 'FAB'); + expect(theme.inputDecorationTheme.focusedBorder, isNotNull, reason: 'Input'); + expect(theme.progressIndicatorTheme.color, isNotNull, reason: 'Progress'); + expect(theme.bottomSheetTheme.backgroundColor, isNotNull, reason: 'BottomSheet'); + expect(theme.dialogTheme.backgroundColor, isNotNull, reason: 'Dialog'); + expect(theme.snackBarTheme.backgroundColor, isNotNull, reason: 'SnackBar'); + expect(theme.listTileTheme.titleTextStyle, isNotNull, reason: 'ListTile'); + expect(theme.textButtonTheme.style, isNotNull, reason: 'TextButton'); + expect(theme.outlinedButtonTheme.style, isNotNull, reason: 'OutlinedButton'); + expect(theme.iconTheme.color, isNotNull, reason: 'IconTheme'); + }); + + test('칩 색이 코스믹 팔레트 안에 있다', () { + // 보라색 칩이 다시 나오면 여기서 잡힌다. + expect(isCosmic(theme.chipTheme.backgroundColor), isTrue); + expect(isCosmic(theme.chipTheme.selectedColor), isTrue); + }); + + test('ColorScheme 의 컨테이너 슬롯이 채워져 있다', () { + // 비워 두면 Material 이 시드에서 만들어 내고, 그 값이 칩·배지로 샌다. + final s = theme.colorScheme; + for (final (name, c) in [ + ('primaryContainer', s.primaryContainer), + ('secondaryContainer', s.secondaryContainer), + ('tertiaryContainer', s.tertiaryContainer), + ]) { + expect(isCosmic(c), isTrue, reason: '$name 이 팔레트 밖이다: $c'); + } + }); + }); +}