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
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""design/design-tokens.md 의 머신블록(<!-- TOKENS --> 아래 yaml) → lib/core/theme/tokens.dart 생성기.
- SoT 는 design/design-tokens.md 하나다. tokens.dart 를 손으로 고치면 시안(시안/assets/ds.css)과 어긋난다.
- 재실행 가능(멱등). 실행: python3 tool/gen_tokens.py (앱 루트 app/ 에서)
- 부수 기능: design-tokens.md 의 contrast_checks 쌍을 WCAG 로 재계산해서 표로 찍고,
AA(4.5:1) 미달이 하나라도 있으면 exit 1. 테마가 대비를 깎았는지 여기서 걸린다.
의도적으로 생성하지 않는 토큰(= CSS 전용 표현이라 Flutter 등가물이 없다):
cosmic_fx.glow-* box-shadow 문자열 (Flutter 는 BoxShadow 로 화면에서 조립)
cosmic_fx.grad-* linear-gradient 문자열
cosmic_fx.nebula 다중 radial-gradient
cosmic_fx.starfield radial-gradient 45개
elevation.* box-shadow 문자열 (Material elevation 과 모델이 다름)
필요해지는 주차에 이 목록에서 하나씩 내려서 추가하면 된다.
"""
import re
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent # app/
TOKENS_MD = ROOT.parent / "design" / "design-tokens.md"
OUT = ROOT / "lib" / "core" / "theme" / "tokens.dart"
SKIP_FX = re.compile(r"^(glow-|grad-|nebula$|starfield$)")
def load_tokens() -> dict:
text = TOKENS_MD.read_text(encoding="utf-8")
marker = text.index("<!-- TOKENS -->")
block = re.search(r"```yaml\n(.*?)\n```", text[marker:], re.S)
if not block:
sys.exit(f"머신블록을 찾지 못했다: {TOKENS_MD}")
return yaml.safe_load(block.group(1))
def camel(name: str, strip: str = "") -> str:
if strip and name.startswith(strip):
name = name[len(strip):]
head, *rest = name.split("-")
ident = head + "".join(p[:1].upper() + p[1:] for p in rest)
return f"s{ident}" if ident[:1].isdigit() else ident # space-4 → s4 (Dart 식별자는 숫자로 시작 못 한다)
def hex_to_rgb(v: str) -> tuple[int, int, int]:
v = v.lstrip("#")
return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)
def dart_color(v: str) -> str:
"""#rrggbb 또는 rgba(r,g,b,a) → Color(0xAARRGGBB)."""
if v.startswith("#"):
r, g, b = hex_to_rgb(v)
a = 255
else:
m = re.match(r"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]*)\s*)?\)", v)
if not m:
raise ValueError(f"색으로 못 읽는 값: {v}")
r, g, b = (int(float(m.group(i))) for i in (1, 2, 3))
a = round(float(m.group(4) or 1) * 255)
return f"Color(0x{a:02X}{r:02X}{g:02X}{b:02X})"
def luminance(v: str) -> float:
def ch(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = hex_to_rgb(v)
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)
def contrast(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def num(v) -> str:
return f"{float(v)}"
def emit_class(name: str, doc: str, lines: list[str]) -> str:
body = "\n".join(f" {ln}" for ln in lines)
return f"/// {doc}\nabstract final class {name} {{\n{body}\n}}\n"
def build(tok: dict) -> str:
colors = tok["colors"]
out = [
"// GENERATED FILE — 손으로 고치지 마라.",
"// 원본: design/design-tokens.md (<!-- TOKENS --> 머신블록)",
"// 재생성: cd app && python3 tool/gen_tokens.py",
"",
"import 'dart:ui' show Color;",
"",
]
# --- colors ---
lines = []
for k, v in colors.items():
lines.append(f"static const {camel(k, 'color-')} = {dart_color(v)}; // {k} {v}")
out.append(emit_class("AppColors", "컬러 토큰. app 은 cosmic* 계열이 기본 서피스다(결정 D-19).", lines))
# --- brand (소셜 버튼 전용) ---
lines = [
f"static const {camel(k, 'brand-')} = {dart_color(v)}; // {k} {v}"
for k, v in tok["brand"].items()
]
out.append(emit_class("BrandColors", "소셜 제공자 브랜드 색. 각 사 가이드라인 고정값이라 테마와 무관하게 그대로 쓴다.", lines))
# --- cosmic_fx 중 Flutter 로 옮길 수 있는 것만 ---
lines, skipped = [], []
for k, v in tok["cosmic_fx"].items():
if SKIP_FX.match(k):
skipped.append(k)
continue
if isinstance(v, str) and v.endswith("px"):
lines.append(f"static const {camel(k)} = {num(v[:-2])}; // {k} {v}")
else:
lines.append(f"static const {camel(k)} = {dart_color(v)}; // {k} {v}")
out.append(emit_class(
"AppFx",
"글래스 FX. CSS 전용 표현(glow-*/grad-*/nebula/starfield)은 생성하지 않는다 — gen_tokens.py 헤더 참고.",
lines,
))
# --- space / radius / sizing / z ---
out.append(emit_class(
"AppSpace", "여백 스케일. 임의값 금지 — 여기 있는 값에서만 고른다.",
[f"static const {camel(k, 'space-')} = {num(v)}; // {k}" for k, v in tok["space"].items()],
))
out.append(emit_class(
"AppRadius", "코너 반경.",
[f"static const {camel(k, 'corner-')} = {num(v)}; // {k}" for k, v in tok["radius"].items()],
))
out.append(emit_class(
"AppSizing", "터치 타깃·본문 최소 크기. app 터치 타깃 48 미만으로 내리지 마라.",
[f"static const {camel(k)} = {num(v)}; // {k}" for k, v in tok["sizing"].items()],
))
out.append(emit_class(
"AppZ", "오버레이 순서.",
[f"static const {camel(k, 'z-')} = {int(v)}; // {k}" for k, v in tok["z"].items()],
))
# --- motion ---
lines = []
for k, v in tok["motion"].items():
if isinstance(v, str) and v.endswith("ms"):
lines.append(f"static const {camel(k, 'motion-')} = Duration(milliseconds: {int(v[:-2])}); // {k}")
else:
lines.append(f"// {k}: {v} (cubic-bezier — Flutter 는 Cubic() 으로 화면에서 조립)")
out.append(emit_class("AppMotion", "모션 지속시간. 이징 곡선은 Cubic 리터럴로 아래 주석 값을 쓴다.", lines))
# --- font families ---
font = tok["font"]
lines = []
for k in ("font-display", "font-body", "font-mono"):
first = font[k].split(",")[0].strip().strip("'\"")
lines.append(f"static const {camel(k, 'font-')} = '{first}'; // {k} = {font[k]}")
lines.append(f"// font-source: {font['font-source']}")
out.append(emit_class(
"AppFonts",
"폰트 패밀리명. 폰트 파일은 리포에 없다 — 미투입 시 시스템 폰트로 폴백된다(README 「미투입 항목」).",
lines,
))
# --- type scale ---
lines = []
for k, v in tok["type"].items():
if "size" not in v:
lines.append(f"// {k}: {v} (크기 없는 지시자 — TextStyle 로 못 옮긴다)")
continue
fam = camel(v["family"], "font-")
parts = [f"family: AppFonts.{fam}", f"size: {num(v['size'])}", f"weight: {int(v['weight'])}"]
if "leading" in v:
parts.append(f"height: {num(v['leading'])}")
if "tracking" in v:
em = float(str(v["tracking"]).replace("em", ""))
parts.append(f"letterSpacing: {em * float(v['size'])}") # em → 논리픽셀
lines.append(f"static const {camel(k, 'type-')} = TypeToken({', '.join(parts)}); // {k}")
out.append(emit_class("AppType", "타입 스케일. 값은 design-tokens.md 의 type 블록 그대로다.", lines))
out.append(
"/// 타입 토큰 1개. app_theme.dart 가 TextStyle 로 변환한다.\n"
"class TypeToken {\n"
" const TypeToken({\n"
" required this.family,\n"
" required this.size,\n"
" required this.weight,\n"
" this.height,\n"
" this.letterSpacing,\n"
" });\n\n"
" final String family;\n"
" final double size;\n"
" final int weight;\n"
" final double? height;\n"
" final double? letterSpacing;\n"
"}\n"
)
if skipped:
out.append(f"// 생성 제외된 cosmic_fx: {', '.join(skipped)}")
return "\n".join(out) + "\n"
def check_contrast(tok: dict) -> bool:
colors = tok["colors"]
print("\n대비 재계산 (design-tokens.md contrast_checks · WCAG 2.1)")
print(f"{'전경':32} {'배경':32} {'비율':>8} 판정")
ok = True
for fg, bg in tok["contrast_checks"]:
ratio = contrast(colors[fg], colors[bg])
verdict = "AA" if ratio >= 4.5 else ("AA-large" if ratio >= 3.0 else "FAIL")
if ratio < 4.5:
ok = False
print(f"{fg:32} {bg:32} {ratio:7.2f}:1 {verdict}")
return ok
def main() -> int:
tok = load_tokens()
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(build(tok), encoding="utf-8")
print(f"생성: {OUT.relative_to(ROOT)} ({len(OUT.read_text(encoding='utf-8').splitlines())}줄)")
if not check_contrast(tok):
print("\n대비 미달 쌍이 있다. design-tokens.md 를 고쳐라 — tokens.dart 를 고치지 마라.")
return 1
print("\n전 쌍 AA 통과.")
return 0
if __name__ == "__main__":
raise SystemExit(main())