Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스

This commit is contained in:
Sn-Kinos
2026-08-10 01:02:03 +09:00
parent fbba4405c3
commit 233ef99837
113 changed files with 8549 additions and 2 deletions
+105
View File
@@ -0,0 +1,105 @@
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,
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),
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)];
+169
View File
@@ -0,0 +1,169 @@
// GENERATED FILE — 손으로 고치지 마라.
// 원본: design/design-tokens.md (<!-- TOKENS --> 머신블록)
// 재생성: cd app && python3 tool/gen_tokens.py
import 'dart:ui' show Color;
/// 컬러 토큰. app 은 cosmic* 계열이 기본 서피스다(결정 D-19).
abstract final class AppColors {
static const primary = Color(0xFFAC5992); // color-primary #ac5992
static const onPrimary = Color(0xFFFFFFFF); // color-on-primary #ffffff
static const primaryContainer = Color(0xFFF0E1EB); // color-primary-container #f0e1eb
static const onPrimaryContainer = Color(0xFF924C7C); // color-on-primary-container #924c7c
static const surface = Color(0xFFF8F5FA); // color-surface #f8f5fa
static const surfaceContainer = Color(0xFFEBE7ED); // color-surface-container #ebe7ed
static const onSurface = Color(0xFF1A1C1E); // color-on-surface #1a1c1e
static const onSurfaceVariant = Color(0xFF4B4753); // color-on-surface-variant #4b4753
static const outline = Color(0xFF7A7481); // color-outline #7a7481
static const error = Color(0xFFC53030); // color-error #c53030
static const onError = Color(0xFFFFFFFF); // color-on-error #ffffff
static const webBackground = Color(0xFFFFFFFF); // web-background #ffffff
static const webForeground = Color(0xFF1A1C1E); // web-foreground #1a1c1e
static const webPrimary = Color(0xFFAC5992); // web-primary #ac5992
static const webPrimaryForeground = Color(0xFFFFFFFF); // web-primary-foreground #ffffff
static const webMuted = Color(0xFFF1EEF4); // web-muted #f1eef4
static const webMutedForeground = Color(0xFF4B4753); // web-muted-foreground #4b4753
static const webBorder = Color(0xFFDED7DF); // web-border #ded7df
static const cosmicVoid = Color(0xFF000000); // color-cosmic-void #000000
static const cosmicSurface = Color(0xFF05070F); // color-cosmic-surface #05070F
static const cosmicSurfaceContainer = Color(0xFF0D1220); // color-cosmic-surface-container #0D1220
static const cosmicSurfaceRaised = Color(0xFF141A2B); // color-cosmic-surface-raised #141A2B
static const onCosmic = Color(0xFFF4F0F7); // color-on-cosmic #F4F0F7
static const onCosmicVariant = Color(0xFFB4AEC0); // color-on-cosmic-variant #B4AEC0
static const cosmicAccent = Color(0xFFFF85D9); // color-cosmic-accent #FF85D9
static const onCosmicAccent = Color(0xFF05070F); // color-on-cosmic-accent #05070F
static const neonCyan = Color(0xFF00F2FF); // color-neon-cyan #00F2FF
static const onNeonCyan = Color(0xFF05070F); // color-on-neon-cyan #05070F
static const neonEmerald = Color(0xFF34D399); // color-neon-emerald #34D399
static const onNeonEmerald = Color(0xFF05070F); // color-on-neon-emerald #05070F
static const neonMagenta = Color(0xFFFF007A); // color-neon-magenta #FF007A
static const onNeonMagenta = Color(0xFF05070F); // color-on-neon-magenta #05070F
static const neonViolet = Color(0xFFA855F7); // color-neon-violet #A855F7
static const onNeonViolet = Color(0xFF05070F); // color-on-neon-violet #05070F
static const cosmicMint = Color(0xFFC1FFED); // color-cosmic-mint #C1FFED
static const onCosmicMint = Color(0xFF05070F); // color-on-cosmic-mint #05070F
static const cosmicOutline = Color(0xFF2A3348); // color-cosmic-outline #2A3348
}
/// 소셜 제공자 브랜드 색. 각 사 가이드라인 고정값이라 테마와 무관하게 그대로 쓴다.
abstract final class BrandColors {
static const kakao = Color(0xFFFEE500); // brand-kakao #FEE500
static const kakaoLabel = Color(0xFF000000); // brand-kakao-label #000000
static const naver = Color(0xFF03C75A); // brand-naver #03C75A
static const naverLabel = Color(0xFFFFFFFF); // brand-naver-label #FFFFFF
static const apple = Color(0xFF000000); // brand-apple #000000
static const appleLabel = Color(0xFFFFFFFF); // brand-apple-label #FFFFFF
static const appleOnlight = Color(0xFFFFFFFF); // brand-apple-onlight #FFFFFF
static const googleFill = Color(0xFFFFFFFF); // brand-google-fill #FFFFFF
static const googleStroke = Color(0xFF747775); // brand-google-stroke #747775
static const googleLabel = Color(0xFF1F1F1F); // brand-google-label #1F1F1F
}
/// 글래스 FX. CSS 전용 표현(glow-*/grad-*/nebula/starfield)은 생성하지 않는다 — gen_tokens.py 헤더 참고.
abstract final class AppFx {
static const glassFill = Color(0x0DFFFFFF); // glass-fill rgba(255,255,255,.05)
static const glassFillStrong = Color(0x14FFFFFF); // glass-fill-strong rgba(255,255,255,.08)
static const glassStroke = Color(0x1AFFFFFF); // glass-stroke rgba(255,255,255,.10)
static const glassBlur = 16.0; // glass-blur 16px
static const orbitStroke = Color(0x1AFFFFFF); // orbit-stroke rgba(255,255,255,.10)
}
/// 여백 스케일. 임의값 금지 — 여기 있는 값에서만 고른다.
abstract final class AppSpace {
static const s0 = 0.0; // space-0
static const s1 = 4.0; // space-1
static const s2 = 8.0; // space-2
static const s3 = 12.0; // space-3
static const s4 = 16.0; // space-4
static const s5 = 24.0; // space-5
static const s6 = 32.0; // space-6
static const s7 = 48.0; // space-7
static const s8 = 64.0; // space-8
static const s9 = 96.0; // space-9
static const card = 24.0; // space-card
static const sectionDesktop = 96.0; // space-section-desktop
static const sectionMobile = 56.0; // space-section-mobile
static const containerMax = 1200.0; // container-max
static const gutterDesktop = 32.0; // gutter-desktop
static const gutterMobile = 16.0; // gutter-mobile
static const measureCh = 70.0; // measure-ch
}
/// 코너 반경.
abstract final class AppRadius {
static const xs = 8.0; // corner-xs
static const sm = 10.0; // corner-sm
static const md = 14.0; // corner-md
static const lg = 18.0; // corner-lg
static const full = 9999.0; // corner-full
}
/// 터치 타깃·본문 최소 크기. app 터치 타깃 48 미만으로 내리지 마라.
abstract final class AppSizing {
static const touchMinApp = 48.0; // touch-min-app
static const touchMinWeb = 36.0; // touch-min-web
static const bodyMinApp = 16.0; // body-min-app
static const bodyMinWeb = 14.0; // body-min-web
}
/// 오버레이 순서.
abstract final class AppZ {
static const base = 0; // z-base
static const sticky = 1100; // z-sticky
static const dropdown = 1200; // z-dropdown
static const popover = 1300; // z-popover
static const overlay = 1400; // z-overlay
static const modal = 1500; // z-modal
static const toast = 1700; // z-toast
}
/// 모션 지속시간. 이징 곡선은 Cubic 리터럴로 아래 주석 값을 쓴다.
abstract final class AppMotion {
static const fast = Duration(milliseconds: 120); // motion-fast
static const base = Duration(milliseconds: 180); // motion-base
static const slow = Duration(milliseconds: 260); // motion-slow
// motion-ease: cubic-bezier(.2,.8,.2,1) (cubic-bezier — Flutter 는 Cubic() 으로 화면에서 조립)
// motion-ease-in-out: cubic-bezier(.4,0,.2,1) (cubic-bezier — Flutter 는 Cubic() 으로 화면에서 조립)
}
/// 폰트 패밀리명. 폰트 파일은 리포에 없다 — 미투입 시 시스템 폰트로 폴백된다(README 「미투입 항목」).
abstract final class AppFonts {
static const display = 'Gmarket Sans'; // font-display = 'Gmarket Sans', Pretendard Variable, sans-serif
static const body = 'Pretendard Variable'; // font-body = Pretendard Variable, system-ui, sans-serif
static const mono = 'JetBrains Mono'; // font-mono = JetBrains Mono, IBM Plex Mono, ui-monospace, monospace
// font-source: Gmarket Sans = webfontworld/jsdelivr · 본문 Pretendard = jsdelivr
}
/// 타입 스케일. 값은 design-tokens.md 의 type 블록 그대로다.
abstract final class AppType {
static const displayL = TypeToken(family: AppFonts.display, size: 44.0, weight: 700, height: 1.06, letterSpacing: -0.44); // type-display-l
static const displayM = TypeToken(family: AppFonts.display, size: 32.0, weight: 700, height: 1.11, letterSpacing: -0.32); // type-display-m
static const titleL = TypeToken(family: AppFonts.display, size: 24.0, weight: 700, height: 1.2, letterSpacing: -0.24); // type-title-l
static const titleM = TypeToken(family: AppFonts.body, size: 18.0, weight: 600, height: 1.3); // type-title-m
static const bodyL = TypeToken(family: AppFonts.body, size: 18.0, weight: 400, height: 1.6); // type-body-l
static const bodyM = TypeToken(family: AppFonts.body, size: 16.0, weight: 400, height: 1.6); // type-body-m
static const bodyS = TypeToken(family: AppFonts.body, size: 14.0, weight: 400, height: 1.55); // type-body-s
static const labelL = TypeToken(family: AppFonts.body, size: 14.0, weight: 500, height: 1.2); // type-label-l
static const eyebrow = TypeToken(family: AppFonts.body, size: 12.0, weight: 600, letterSpacing: 0.72); // type-eyebrow
static const caption = TypeToken(family: AppFonts.body, size: 12.0, weight: 500, height: 1.4); // type-caption
// type-mono-num: {'family': 'font-mono', 'feature': 'tnum', 'note': '가격·지표·표는 tabular'} (크기 없는 지시자 — TextStyle 로 못 옮긴다)
}
/// 타입 토큰 1개. app_theme.dart 가 TextStyle 로 변환한다.
class TypeToken {
const TypeToken({
required this.family,
required this.size,
required this.weight,
this.height,
this.letterSpacing,
});
final String family;
final double size;
final int weight;
final double? height;
final double? letterSpacing;
}
// 생성 제외된 cosmic_fx: glow-sm, glow-md, glow-lg, glow-accent, glow-cyan, glow-emerald, glow-magenta, grad-action, grad-brand, grad-eclipse, grad-veil, nebula, starfield