diff --git a/flutter/assets/fonts/ChungbukNationalUniversity-Bold.ttf b/flutter/assets/fonts/ChungbukNationalUniversity-Bold.ttf
new file mode 100644
index 0000000..291b973
Binary files /dev/null and b/flutter/assets/fonts/ChungbukNationalUniversity-Bold.ttf differ
diff --git a/flutter/assets/fonts/ChungbukNationalUniversity-Regular.ttf b/flutter/assets/fonts/ChungbukNationalUniversity-Regular.ttf
new file mode 100644
index 0000000..e9d1a3f
Binary files /dev/null and b/flutter/assets/fonts/ChungbukNationalUniversity-Regular.ttf differ
diff --git a/flutter/assets/fonts/README.md b/flutter/assets/fonts/README.md
new file mode 100644
index 0000000..4f2648a
--- /dev/null
+++ b/flutter/assets/fonts/README.md
@@ -0,0 +1,13 @@
+# 폰트 출처
+
+시안(`.design/styles.css`)이 웹 CDN 에서 불러오는 서체를 앱에 번들했다.
+웹폰트(woff)만 배포되는 서체는 TTF 로 변환했다(테이블 압축만 해제, 글리프 변경 없음).
+
+| 파일 | 서체 | 출처 | 라이선스 |
+| --- | --- | --- | --- |
+| `SUITE-*.ttf` | SUITE | https://github.com/sun-typeface/SUITE | SIL OFL 1.1 |
+| `ChungbukNationalUniversity-*.ttf` | 충북대학교 70주년 기념체 | 눈누(noonfonts_2201-2) woff → TTF 변환 | 배포처 약관 확인 필요 |
+| `UhBeeKeongKeong.ttf` | 어비 꿍꿍체 | 눈누(noonfonts_five) woff → TTF 변환 | 배포처 약관 확인 필요 |
+
+> ⚠️ 충북대체·어비체는 **운영 배포 전에 원 배포처의 상업적 이용·앱 임베딩 허용 범위를 확인**한다.
+> 가능하면 원 배포처의 TTF/OTF 원본으로 교체한다.
diff --git a/flutter/assets/fonts/SUITE-Bold.ttf b/flutter/assets/fonts/SUITE-Bold.ttf
new file mode 100644
index 0000000..3f5f63b
Binary files /dev/null and b/flutter/assets/fonts/SUITE-Bold.ttf differ
diff --git a/flutter/assets/fonts/SUITE-ExtraBold.ttf b/flutter/assets/fonts/SUITE-ExtraBold.ttf
new file mode 100644
index 0000000..203b0ec
Binary files /dev/null and b/flutter/assets/fonts/SUITE-ExtraBold.ttf differ
diff --git a/flutter/assets/fonts/SUITE-Medium.ttf b/flutter/assets/fonts/SUITE-Medium.ttf
new file mode 100644
index 0000000..6fc11a4
Binary files /dev/null and b/flutter/assets/fonts/SUITE-Medium.ttf differ
diff --git a/flutter/assets/fonts/SUITE-Regular.ttf b/flutter/assets/fonts/SUITE-Regular.ttf
new file mode 100644
index 0000000..0b35dd6
Binary files /dev/null and b/flutter/assets/fonts/SUITE-Regular.ttf differ
diff --git a/flutter/assets/fonts/SUITE-SemiBold.ttf b/flutter/assets/fonts/SUITE-SemiBold.ttf
new file mode 100644
index 0000000..fc04f78
Binary files /dev/null and b/flutter/assets/fonts/SUITE-SemiBold.ttf differ
diff --git a/flutter/assets/fonts/UhBeeKeongKeong.ttf b/flutter/assets/fonts/UhBeeKeongKeong.ttf
new file mode 100644
index 0000000..374dec6
Binary files /dev/null and b/flutter/assets/fonts/UhBeeKeongKeong.ttf differ
diff --git a/flutter/assets/images/login/login_cat.png b/flutter/assets/images/login/login_cat.png
new file mode 100644
index 0000000..ed3853d
Binary files /dev/null and b/flutter/assets/images/login/login_cat.png differ
diff --git a/flutter/assets/images/login/mark_apple.png b/flutter/assets/images/login/mark_apple.png
new file mode 100644
index 0000000..d7e292f
Binary files /dev/null and b/flutter/assets/images/login/mark_apple.png differ
diff --git a/flutter/assets/images/onboarding/onb1_base.png b/flutter/assets/images/onboarding/onb1_base.png
new file mode 100644
index 0000000..ce56348
Binary files /dev/null and b/flutter/assets/images/onboarding/onb1_base.png differ
diff --git a/flutter/assets/images/onboarding/onb1_ch.png b/flutter/assets/images/onboarding/onb1_ch.png
new file mode 100644
index 0000000..9ac48a9
Binary files /dev/null and b/flutter/assets/images/onboarding/onb1_ch.png differ
diff --git a/flutter/assets/images/onboarding/onb2_base.png b/flutter/assets/images/onboarding/onb2_base.png
new file mode 100644
index 0000000..b655c4e
Binary files /dev/null and b/flutter/assets/images/onboarding/onb2_base.png differ
diff --git a/flutter/assets/images/onboarding/onb2_ch.png b/flutter/assets/images/onboarding/onb2_ch.png
new file mode 100644
index 0000000..4ec76ad
Binary files /dev/null and b/flutter/assets/images/onboarding/onb2_ch.png differ
diff --git a/flutter/assets/images/onboarding/onb3_base.png b/flutter/assets/images/onboarding/onb3_base.png
new file mode 100644
index 0000000..e706ff8
Binary files /dev/null and b/flutter/assets/images/onboarding/onb3_base.png differ
diff --git a/flutter/assets/images/onboarding/onb3_hero.png b/flutter/assets/images/onboarding/onb3_hero.png
new file mode 100644
index 0000000..e69f9cc
Binary files /dev/null and b/flutter/assets/images/onboarding/onb3_hero.png differ
diff --git a/flutter/assets/images/onboarding/onb3_sp1.png b/flutter/assets/images/onboarding/onb3_sp1.png
new file mode 100644
index 0000000..a6c9c01
Binary files /dev/null and b/flutter/assets/images/onboarding/onb3_sp1.png differ
diff --git a/flutter/assets/images/onboarding/onb3_sp2.png b/flutter/assets/images/onboarding/onb3_sp2.png
new file mode 100644
index 0000000..49c5623
Binary files /dev/null and b/flutter/assets/images/onboarding/onb3_sp2.png differ
diff --git a/flutter/assets/images/onboarding/onb3_sp3.png b/flutter/assets/images/onboarding/onb3_sp3.png
new file mode 100644
index 0000000..b82b428
Binary files /dev/null and b/flutter/assets/images/onboarding/onb3_sp3.png differ
diff --git a/flutter/assets/images/onboarding/onb4_base.png b/flutter/assets/images/onboarding/onb4_base.png
new file mode 100644
index 0000000..efc9f7c
Binary files /dev/null and b/flutter/assets/images/onboarding/onb4_base.png differ
diff --git a/flutter/assets/images/onboarding/onb4_ch.png b/flutter/assets/images/onboarding/onb4_ch.png
new file mode 100644
index 0000000..c447fc7
Binary files /dev/null and b/flutter/assets/images/onboarding/onb4_ch.png differ
diff --git a/flutter/assets/images/splash/bg_objects.png b/flutter/assets/images/splash/bg_objects.png
new file mode 100644
index 0000000..2cac393
Binary files /dev/null and b/flutter/assets/images/splash/bg_objects.png differ
diff --git a/flutter/assets/images/splash/blob.png b/flutter/assets/images/splash/blob.png
new file mode 100644
index 0000000..79d2c4d
Binary files /dev/null and b/flutter/assets/images/splash/blob.png differ
diff --git a/flutter/assets/images/splash/dog.png b/flutter/assets/images/splash/dog.png
new file mode 100644
index 0000000..5047d00
Binary files /dev/null and b/flutter/assets/images/splash/dog.png differ
diff --git a/flutter/assets/images/splash/letter_p.png b/flutter/assets/images/splash/letter_p.png
new file mode 100644
index 0000000..e5438d2
Binary files /dev/null and b/flutter/assets/images/splash/letter_p.png differ
diff --git a/flutter/assets/images/splash/letter_r.png b/flutter/assets/images/splash/letter_r.png
new file mode 100644
index 0000000..8e58f1e
Binary files /dev/null and b/flutter/assets/images/splash/letter_r.png differ
diff --git a/flutter/assets/images/splash/letter_u.png b/flutter/assets/images/splash/letter_u.png
new file mode 100644
index 0000000..ef3aaf1
Binary files /dev/null and b/flutter/assets/images/splash/letter_u.png differ
diff --git a/flutter/assets/svg/intro/intro_ball.svg b/flutter/assets/svg/intro/intro_ball.svg
new file mode 100644
index 0000000..50bc85e
--- /dev/null
+++ b/flutter/assets/svg/intro/intro_ball.svg
@@ -0,0 +1,74 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/intro/intro_dog.svg b/flutter/assets/svg/intro/intro_dog.svg
new file mode 100644
index 0000000..b17af15
--- /dev/null
+++ b/flutter/assets/svg/intro/intro_dog.svg
@@ -0,0 +1,19 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/login/login_back.svg b/flutter/assets/svg/login/login_back.svg
new file mode 100644
index 0000000..b446379
--- /dev/null
+++ b/flutter/assets/svg/login/login_back.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/login/login_paw.svg b/flutter/assets/svg/login/login_paw.svg
new file mode 100644
index 0000000..f5c3162
--- /dev/null
+++ b/flutter/assets/svg/login/login_paw.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/login/login_wand.svg b/flutter/assets/svg/login/login_wand.svg
new file mode 100644
index 0000000..5313f7c
--- /dev/null
+++ b/flutter/assets/svg/login/login_wand.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/login/mark_kakao.svg b/flutter/assets/svg/login/mark_kakao.svg
new file mode 100644
index 0000000..c3c21d2
--- /dev/null
+++ b/flutter/assets/svg/login/mark_kakao.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/login/mark_naver.svg b/flutter/assets/svg/login/mark_naver.svg
new file mode 100644
index 0000000..0a0c3fc
--- /dev/null
+++ b/flutter/assets/svg/login/mark_naver.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart
new file mode 100644
index 0000000..e6ac336
--- /dev/null
+++ b/flutter/lib/core/constants/app_assets.dart
@@ -0,0 +1,36 @@
+/// 에셋 경로 상수 — 문자열 경로를 위젯에 직접 적지 않는다.
+abstract final class AppAssets {
+ // 스플래시
+ static const String splashBlob = 'assets/images/splash/blob.png';
+ static const String splashObjects = 'assets/images/splash/bg_objects.png';
+ static const String splashLetterR = 'assets/images/splash/letter_r.png';
+ static const String splashLetterU = 'assets/images/splash/letter_u.png';
+ static const String splashLetterP = 'assets/images/splash/letter_p.png';
+ static const String splashDog = 'assets/images/splash/dog.png';
+
+ // 인트로
+ static const String introDog = 'assets/svg/intro/intro_dog.svg';
+ static const String introBall = 'assets/svg/intro/intro_ball.svg';
+
+ // 로그인
+ static const String loginBack = 'assets/svg/login/login_back.svg';
+ static const String loginPaw = 'assets/svg/login/login_paw.svg';
+ static const String loginWand = 'assets/svg/login/login_wand.svg';
+ static const String loginCat = 'assets/images/login/login_cat.png';
+ static const String markNaver = 'assets/svg/login/mark_naver.svg';
+ static const String markKakao = 'assets/svg/login/mark_kakao.svg';
+ static const String markApple = 'assets/images/login/mark_apple.png';
+
+ // 온보딩 — 시안 SVG 를 움직이는 부분(ch/sp)과 배경으로 나눠 래스터화했다
+ static const String onb1Base = 'assets/images/onboarding/onb1_base.png';
+ static const String onb1Character = 'assets/images/onboarding/onb1_ch.png';
+ static const String onb2Base = 'assets/images/onboarding/onb2_base.png';
+ static const String onb2Character = 'assets/images/onboarding/onb2_ch.png';
+ static const String onb3Base = 'assets/images/onboarding/onb3_base.png';
+ static const String onb3Hero = 'assets/images/onboarding/onb3_hero.png';
+ static const String onb3Sparkle1 = 'assets/images/onboarding/onb3_sp1.png';
+ static const String onb3Sparkle2 = 'assets/images/onboarding/onb3_sp2.png';
+ static const String onb3Sparkle3 = 'assets/images/onboarding/onb3_sp3.png';
+ static const String onb4Base = 'assets/images/onboarding/onb4_base.png';
+ static const String onb4Heart = 'assets/images/onboarding/onb4_ch.png';
+}
diff --git a/flutter/lib/core/constants/app_colors.dart b/flutter/lib/core/constants/app_colors.dart
new file mode 100644
index 0000000..5c82575
--- /dev/null
+++ b/flutter/lib/core/constants/app_colors.dart
@@ -0,0 +1,51 @@
+import 'package:flutter/painting.dart';
+
+/// 시안(.design/styles.css `:root`)의 색상 토큰.
+/// 표에 없는 색을 새로 쓰기 전에 여기에 먼저 추가한다.
+abstract final class AppColors {
+ // 브랜드 — primary 파생색은 시안의 color-mix 결과를 계산해 고정했다
+ static const Color primary = Color(0xFFFF7500);
+ static const Color primary600 = Color(0xFFD96200); // mix(primary, #2b0d00 18%)
+ static const Color primary100 = Color(0xFFFFD8B8); // mix(primary, #fff 72%)
+ static const Color primary50 = Color(0xFFFFE9D6); // mix(primary, #fffaf6 87%)
+ static const Color primaryBorder = Color(0xFFE06700); // mix(primary, #000 12%)
+
+ // 건강·긍정(민트), 추억보관함(보라)
+ static const Color accent = Color(0xFF3FB58C);
+ static const Color accent100 = Color(0xFFDBF1E8);
+ static const Color memory = Color(0xFFC084FC);
+ static const Color memory50 = Color(0xFFF4ECFE);
+
+ // 면 · 글자 · 선
+ static const Color bg = Color(0xFFFFFDFB);
+ static const Color surface = Color(0xFFFFFFFF);
+ static const Color surface2 = Color(0xFFFFF7F1);
+ static const Color ink = Color(0xFF1F1F1F);
+ static const Color ink2 = Color(0xFF6E665C);
+ static const Color muted = Color(0xFFA89E92);
+ static const Color line = Color(0xFFEFE6DB);
+ static const Color line2 = Color(0xFFF6EFE7);
+
+ // 일정 분류색 (고정)
+ static const Color catHospital = Color(0xFFFF6B6B);
+ static const Color catVaccine = Color(0xFF4DA6E0);
+ static const Color catGrooming = Color(0xFFB07CF0);
+ static const Color catWalk = Color(0xFF3FB58C);
+ static const Color catFood = Color(0xFFFFAA4D);
+ static const Color catEtc = Color(0xFF9AA0A6);
+
+ // 하단 탭바 반투명 배경 — rgba(255,253,250,.86)
+ static const Color tabBarBg = Color(0xDBFFFDFA);
+
+ // 진입 화면 전용
+ static const Color splashYellow = Color(0xFFFFDE84);
+ static const Color splashInk = Color(0xFF2B2622);
+ static const Color offWhite = Color(0xFFFDFDFD); // 인트로·온보딩 흰 버튼
+ static const Color onboardingHint = Color(0xFFC8C8C8);
+
+ // 소셜 로그인 브랜드색 — 각 사 가이드 색상
+ static const Color naver = Color(0xFF00D03F);
+ static const Color kakao = Color(0xFFFAE100);
+ static const Color googleBorder = Color(0xFFE8EEF2);
+ static const Color apple = Color(0xFF000000);
+}
diff --git a/flutter/lib/core/constants/app_sizes.dart b/flutter/lib/core/constants/app_sizes.dart
new file mode 100644
index 0000000..b1bc9bf
--- /dev/null
+++ b/flutter/lib/core/constants/app_sizes.dart
@@ -0,0 +1,36 @@
+import 'package:flutter/widgets.dart';
+
+/// 시안 기기 크기 — iPhone 16 Pro(402×874) 기준으로 그려졌다.
+/// 일러스트 배치처럼 시안 좌표를 그대로 옮기는 곳에서만 쓰고,
+/// 실제 크기는 [DesignArtBox] 등이 화면 비율로 환산한다.
+const Size kDesignScreen = Size(402, 874);
+
+/// 여백·간격 토큰 (시안의 반복 값)
+abstract final class AppSpacing {
+ static const double xs = 4;
+ static const double sm = 8;
+ static const double md = 12;
+ static const double lg = 16;
+ static const double xl = 20; // 화면 좌우 기본 여백
+ static const double xxl = 24;
+}
+
+/// 모서리 반경 토큰
+abstract final class AppRadius {
+ static const double card = 10; // --radius
+ static const double pill = 999;
+}
+
+/// 공통 컨트롤 크기
+abstract final class AppControlSize {
+ static const double buttonHeight = 46; // 인트로·로그인 pill 버튼
+ static const double navBarHeight = 44; // 상단 내비게이션 바
+ static const double tabIcon = 24;
+}
+
+/// 애니메이션 시간
+abstract final class AppDurations {
+ static const Duration fast = Duration(milliseconds: 150);
+ static const Duration press = Duration(milliseconds: 100);
+ static const Duration fade = Duration(milliseconds: 480);
+}
diff --git a/flutter/lib/core/storage/preferences_provider.dart b/flutter/lib/core/storage/preferences_provider.dart
new file mode 100644
index 0000000..eeba1da
--- /dev/null
+++ b/flutter/lib/core/storage/preferences_provider.dart
@@ -0,0 +1,8 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// 앱 전역 SharedPreferences — main() 에서 미리 로드해 override 한다.
+/// 토큰 같은 민감정보는 여기 저장하지 않는다 (flutter_secure_storage 사용).
+final sharedPreferencesProvider = Provider((ref) {
+ throw UnimplementedError('main() 에서 sharedPreferencesProvider 를 override 해야 한다');
+});
diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart
new file mode 100644
index 0000000..4288323
--- /dev/null
+++ b/flutter/lib/core/theme/app_theme.dart
@@ -0,0 +1,31 @@
+import 'package:flutter/material.dart';
+import 'package:tma_app/core/constants/app_colors.dart';
+import 'package:tma_app/core/theme/app_typography.dart';
+
+/// 앱 테마 — 시안은 밝은 단일 테마다(다크 모드 없음).
+abstract final class AppTheme {
+ static ThemeData get light {
+ final colorScheme = ColorScheme.fromSeed(
+ seedColor: AppColors.primary,
+ primary: AppColors.primary,
+ onPrimary: Colors.white,
+ secondary: AppColors.accent,
+ surface: AppColors.surface,
+ onSurface: AppColors.ink,
+ outline: AppColors.line,
+ );
+
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: colorScheme,
+ scaffoldBackgroundColor: AppColors.bg,
+ fontFamily: AppFonts.ui,
+ textTheme: AppTypography.textTheme,
+ iconTheme: const IconThemeData(color: AppColors.ink2),
+ // 시안은 물결(ripple) 대신 눌림 축소 효과를 쓴다 — Pressable 참고
+ splashFactory: NoSplash.splashFactory,
+ highlightColor: Colors.transparent,
+ splashColor: Colors.transparent,
+ );
+ }
+}
diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart
new file mode 100644
index 0000000..9691e0e
--- /dev/null
+++ b/flutter/lib/core/theme/app_typography.dart
@@ -0,0 +1,69 @@
+import 'package:flutter/material.dart';
+import 'package:tma_app/core/constants/app_colors.dart';
+
+/// 서체 패밀리 이름 — pubspec.yaml 의 fonts 항목과 일치해야 한다.
+abstract final class AppFonts {
+ /// 본문(UI) — 시안의 --font-ui
+ static const String ui = 'SUITE';
+
+ /// 제목 — 시안의 --font-display (Bold 컷을 400 으로 등록, 굵게 요청 시 합성)
+ static const String display = 'ChungbukBold';
+
+ /// 제목 서체의 일반 굵기 (펫 명함 이름 등)
+ static const String displayRegular = 'Chungbuk';
+
+ /// 인트로 손글씨
+ static const String handwriting = 'UhBeeKeongKeong';
+}
+
+/// 시안 전역 자간 -0.01em
+double _tracking(double fontSize) => fontSize * -0.01;
+
+/// 앱 TextTheme — 크기는 시안 px(=논리 픽셀) 값을 그대로 쓴다.
+/// 화면에서는 `Theme.of(context).textTheme.xxx` 로 꺼내 쓰고, 굵기·색만 copyWith 로 조정한다.
+abstract final class AppTypography {
+ static TextStyle _ui(double size, FontWeight weight, {double? height}) =>
+ TextStyle(
+ fontFamily: AppFonts.ui,
+ fontSize: size,
+ fontWeight: weight,
+ height: height,
+ letterSpacing: _tracking(size),
+ color: AppColors.ink,
+ );
+
+ static TextStyle _display(double size, FontWeight weight, {double? height}) =>
+ TextStyle(
+ fontFamily: AppFonts.display,
+ fontSize: size,
+ fontWeight: weight,
+ height: height,
+ letterSpacing: 0,
+ color: AppColors.ink,
+ );
+
+ static final TextTheme textTheme = TextTheme(
+ // 26 — 로그인 인사말 등 큰 제목
+ displaySmall: _display(26, FontWeight.w400),
+ // 24 — 화면 헤더 제목(.rup-head__title)
+ headlineSmall: _display(24, FontWeight.w800),
+ // 20 — 달력 월 표시
+ titleLarge: _display(20, FontWeight.w700),
+ // 17 — 섹션 제목(.rup-section-title h2)
+ titleMedium: _display(17, FontWeight.w700),
+ // 15 — 내비 제목·목록 제목
+ titleSmall: _ui(15, FontWeight.w700),
+ // 14.5 — 본문 강조
+ bodyLarge: _ui(14.5, FontWeight.w400, height: 1.62),
+ // 13.5 — 본문 보조·안내
+ bodyMedium: _ui(13.5, FontWeight.w400, height: 1.6),
+ // 12 — 메타 정보
+ bodySmall: _ui(12, FontWeight.w400),
+ // 15 — 버튼
+ labelLarge: _ui(15, FontWeight.w700),
+ // 13 — 작은 버튼·링크
+ labelMedium: _ui(13, FontWeight.w600),
+ // 12 — 탭 라벨
+ labelSmall: _ui(12, FontWeight.w700),
+ );
+}
diff --git a/flutter/lib/core/utils/keyframes.dart b/flutter/lib/core/utils/keyframes.dart
new file mode 100644
index 0000000..774b5c6
--- /dev/null
+++ b/flutter/lib/core/utils/keyframes.dart
@@ -0,0 +1,30 @@
+import 'package:flutter/animation.dart';
+
+/// 전체 시간축 [elapsed] 에서 [start] 부터 [duration] 동안의 진행률(0~1).
+/// 시작 전은 0, 끝난 뒤는 1 — CSS `animation-fill-mode: both` 와 같다.
+double segmentProgress(double elapsed, double start, double duration) {
+ if (duration <= 0) return elapsed >= start ? 1 : 0;
+ return ((elapsed - start) / duration).clamp(0.0, 1.0);
+}
+
+/// CSS `@keyframes` 보간. [stops] 는 0~1 오름차순 위치, [values] 는 각 위치의 값이다.
+/// CSS 와 마찬가지로 [curve] 는 전체가 아니라 **키프레임 구간마다** 적용된다.
+double keyframeValue(
+ double t,
+ List stops,
+ List values, {
+ Curve curve = Curves.linear,
+}) {
+ assert(stops.length == values.length && stops.length >= 2);
+ if (t <= stops.first) return values.first;
+ for (var i = 0; i < stops.length - 1; i++) {
+ final from = stops[i];
+ final to = stops[i + 1];
+ if (t <= to) {
+ final local = to == from ? 1.0 : (t - from) / (to - from);
+ final eased = curve.transform(local);
+ return values[i] + (values[i + 1] - values[i]) * eased;
+ }
+ }
+ return values.last;
+}
diff --git a/flutter/lib/shared/widgets/design_art_box.dart b/flutter/lib/shared/widgets/design_art_box.dart
new file mode 100644
index 0000000..ad4f510
--- /dev/null
+++ b/flutter/lib/shared/widgets/design_art_box.dart
@@ -0,0 +1,75 @@
+import 'dart:math' as math;
+
+import 'package:flutter/widgets.dart';
+import 'package:tma_app/core/constants/app_sizes.dart';
+
+/// 시안 좌표로 배치된 일러스트 묶음을 실제 화면 크기에 맞춰 그린다.
+///
+/// [designSize] 는 묶음 전체의 시안 크기이고, [children] 은 묶음 왼쪽 위를 원점으로 한
+/// 시안 좌표의 [Positioned] 들이다. 화면 너비 비율(화면폭 / 402)로 확대·축소하되,
+/// 부모가 준 높이가 모자라면 비율을 유지한 채 더 줄인다.
+/// 좌표 숫자는 "시안 단위"이며 실제 픽셀이 아니다.
+class DesignArtBox extends StatelessWidget {
+ const DesignArtBox({
+ super.key,
+ required this.designSize,
+ required this.children,
+ });
+
+ final Size designSize;
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ final screenWidth = MediaQuery.sizeOf(context).width;
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final widthScale = screenWidth / kDesignScreen.width;
+ final heightScale = constraints.hasBoundedHeight
+ ? constraints.maxHeight / designSize.height
+ : double.infinity;
+ final scale = math.min(widthScale, heightScale);
+ return SizedBox(
+ width: designSize.width * scale,
+ height: designSize.height * scale,
+ child: FittedBox(
+ child: SizedBox.fromSize(
+ size: designSize,
+ child: Stack(clipBehavior: Clip.none, children: children),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+/// 시안 전체 화면(402×874)을 기준으로 그린 장면을 화면을 덮도록(cover) 맞춘다.
+/// 스플래시·온보딩처럼 화면 전체가 일러스트인 곳에서 쓴다.
+class DesignCover extends StatelessWidget {
+ const DesignCover({
+ super.key,
+ required this.designSize,
+ required this.child,
+ this.fit = BoxFit.cover,
+ this.alignment = Alignment.center,
+ });
+
+ final Size designSize;
+ final Widget child;
+ final BoxFit fit;
+ final Alignment alignment;
+
+ @override
+ Widget build(BuildContext context) {
+ return ClipRect(
+ child: SizedBox.expand(
+ child: FittedBox(
+ fit: fit,
+ alignment: alignment,
+ child: SizedBox.fromSize(size: designSize, child: child),
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter/lib/shared/widgets/drop_shadow_image.dart b/flutter/lib/shared/widgets/drop_shadow_image.dart
new file mode 100644
index 0000000..ac3f8f8
--- /dev/null
+++ b/flutter/lib/shared/widgets/drop_shadow_image.dart
@@ -0,0 +1,44 @@
+import 'dart:ui' show ImageFilter;
+
+import 'package:flutter/widgets.dart';
+
+/// CSS `filter: drop-shadow(x y blur color)` — 투명 영역을 뺀 그림 모양대로 그림자를 드리운다.
+/// [blur] 는 CSS 의 blur 반경(px)이며 내부에서 표준편차(반경/2)로 바꾼다.
+class DropShadowImage extends StatelessWidget {
+ const DropShadowImage({
+ super.key,
+ required this.asset,
+ required this.shadowColor,
+ required this.offset,
+ required this.blur,
+ });
+
+ final String asset;
+ final Color shadowColor;
+ final Offset offset;
+ final double blur;
+
+ @override
+ Widget build(BuildContext context) {
+ final sigma = blur / 2;
+ return Stack(
+ fit: StackFit.expand,
+ clipBehavior: Clip.none,
+ children: [
+ Transform.translate(
+ offset: offset,
+ child: ImageFiltered(
+ imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
+ child: Image.asset(
+ asset,
+ fit: BoxFit.fill,
+ color: shadowColor,
+ colorBlendMode: BlendMode.srcIn,
+ ),
+ ),
+ ),
+ Image.asset(asset, fit: BoxFit.fill),
+ ],
+ );
+ }
+}
diff --git a/flutter/lib/shared/widgets/icons/rup_icon.dart b/flutter/lib/shared/widgets/icons/rup_icon.dart
new file mode 100644
index 0000000..ebe2ef7
--- /dev/null
+++ b/flutter/lib/shared/widgets/icons/rup_icon.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:tma_app/shared/widgets/icons/rup_icon_paths.dart';
+
+export 'package:tma_app/shared/widgets/icons/rup_icon_paths.dart';
+
+const double _kDefaultIconSize = 22;
+const double _kDefaultStroke = 1.8;
+
+/// 시안의 `` — 24 뷰박스 선 아이콘.
+/// 색을 주지 않으면 주변 [IconTheme] 색(없으면 글자색)을 따른다.
+class RupIcon extends StatelessWidget {
+ const RupIcon(
+ this.name, {
+ super.key,
+ this.size = _kDefaultIconSize,
+ this.stroke = _kDefaultStroke,
+ this.color,
+ this.semanticLabel,
+ });
+
+ final RupIconName name;
+ final double size;
+ final double stroke;
+ final Color? color;
+ final String? semanticLabel;
+
+ @override
+ Widget build(BuildContext context) {
+ return SvgPicture.string(
+ '',
+ width: size,
+ height: size,
+ theme: SvgTheme(currentColor: _resolveColor(context, color)),
+ semanticsLabel: semanticLabel,
+ excludeFromSemantics: semanticLabel == null,
+ );
+ }
+}
+
+/// 시안의 전용 글리프(PetCardGlyph 등)
+class RupGlyphIcon extends StatelessWidget {
+ const RupGlyphIcon(
+ this.glyph, {
+ super.key,
+ this.size = _kDefaultIconSize,
+ this.color,
+ this.semanticLabel,
+ });
+
+ final RupGlyph glyph;
+ final double size;
+ final Color? color;
+ final String? semanticLabel;
+
+ @override
+ Widget build(BuildContext context) {
+ final vb = glyph.viewBox;
+ final paint = glyph.fill
+ ? 'fill="currentColor" stroke="none"'
+ : 'fill="none"${glyph.stroke != null ? ' stroke="currentColor" stroke-width="${glyph.stroke}"' : ''}';
+ return SvgPicture.string(
+ '',
+ width: size,
+ height: size,
+ theme: SvgTheme(currentColor: _resolveColor(context, color)),
+ semanticsLabel: semanticLabel,
+ excludeFromSemantics: semanticLabel == null,
+ );
+ }
+}
+
+Color _resolveColor(BuildContext context, Color? color) {
+ return color ??
+ IconTheme.of(context).color ??
+ DefaultTextStyle.of(context).style.color ??
+ const Color(0xFF000000);
+}
diff --git a/flutter/lib/shared/widgets/icons/rup_icon_paths.dart b/flutter/lib/shared/widgets/icons/rup_icon_paths.dart
new file mode 100644
index 0000000..cd0d752
--- /dev/null
+++ b/flutter/lib/shared/widgets/icons/rup_icon_paths.dart
@@ -0,0 +1,208 @@
+// 시안(.design/app-data.jsx)의 ICON_PATHS · *_GLYPH 를 옮긴 아이콘 경로.
+// 새 아이콘은 시안과 같은 규격(24 뷰박스, stroke=currentColor, round cap/join)으로 추가한다.
+
+/// 선 아이콘 — 24×24 뷰박스, 채우지 않고 currentColor 로 선만 그린다.
+enum RupIconName {
+ home(
+ '',
+ ),
+ calendar(
+ '',
+ ),
+ diary(
+ '',
+ ),
+ paw(
+ '',
+ ),
+ user(
+ '',
+ ),
+ bell(
+ '',
+ ),
+ plus(
+ '',
+ ),
+ minus(
+ '',
+ ),
+ check(
+ '',
+ ),
+ chevR(
+ '',
+ ),
+ chevL(
+ '',
+ ),
+ chevD(
+ '',
+ ),
+ chevU(
+ '',
+ ),
+ share(
+ '',
+ ),
+ download(
+ '',
+ ),
+ more(
+ '',
+ ),
+ moreV(
+ '',
+ ),
+ edit(
+ '',
+ ),
+ pencilCute(
+ '',
+ ),
+ x(
+ '',
+ ),
+ heart(
+ '',
+ ),
+ comment(
+ '',
+ ),
+ text(
+ '',
+ ),
+ vaccine(
+ '',
+ ),
+ hospital(
+ '',
+ ),
+ grooming(
+ '',
+ ),
+ walk(
+ '',
+ ),
+ food(
+ '',
+ ),
+ tree(
+ '',
+ ),
+ repeat(
+ '',
+ ),
+ pill(
+ '',
+ ),
+ cake(
+ '',
+ ),
+ weight(
+ '',
+ ),
+ male(
+ '',
+ ),
+ female(
+ '',
+ ),
+ alert(
+ '',
+ ),
+ shield(
+ '',
+ ),
+ camera(
+ '',
+ ),
+ battery(
+ '',
+ ),
+ qr(
+ '',
+ ),
+ copy(
+ '',
+ ),
+ gear(
+ '',
+ ),
+ users(
+ '',
+ ),
+ clock(
+ '',
+ ),
+ location(
+ '',
+ ),
+ search(
+ '',
+ ),
+ sparkle(
+ '',
+ ),
+ qrcode(
+ '',
+ ),
+ link(
+ '',
+ ),
+ star(
+ '',
+ ),
+ logout(
+ '',
+ ),
+ moon(
+ '',
+ ),
+ memory(
+ '',
+ ),
+ trash(
+ '',
+ ),
+ shuffle(
+ '',
+ ),
+ dice(
+ '',
+ );
+
+ const RupIconName(this.paths);
+
+ /// SVG 내부 요소 (svg 태그 제외)
+ final String paths;
+}
+
+/// 전용 글리프 — 뷰박스와 채움 방식이 제각각이라 SVG 본문을 통째로 보관한다.
+enum RupGlyph {
+ /// 펫 명함 (하단 탭)
+ petCard(24, ''),
+
+ /// 다이어리
+ diary(16, '', fill: true),
+
+ /// 추억보관함 (구름)
+ memory(15, ''),
+
+ /// 설정 톱니
+ gear(24, '', stroke: 2.2),
+
+ /// QR 코드
+ qr(15, '', fill: true);
+
+ const RupGlyph(this.viewBox, this.paths, {this.fill = false, this.stroke});
+
+ final double viewBox;
+ final String paths;
+
+ /// true 면 currentColor 로 채우고, false 면 요소별 속성을 그대로 쓴다
+ final bool fill;
+
+ /// 선 굵기가 지정된 글리프 (null 이면 요소별 속성)
+ final double? stroke;
+}
diff --git a/flutter/lib/shared/widgets/pressable.dart b/flutter/lib/shared/widgets/pressable.dart
new file mode 100644
index 0000000..ff05bda
--- /dev/null
+++ b/flutter/lib/shared/widgets/pressable.dart
@@ -0,0 +1,62 @@
+import 'package:flutter/material.dart';
+import 'package:tma_app/core/constants/app_sizes.dart';
+
+/// 시안의 `:active { transform: scale(.95) }` 눌림 효과.
+/// 물결 효과 없이 살짝 줄어들었다 돌아온다.
+class Pressable extends StatefulWidget {
+ const Pressable({
+ super.key,
+ required this.child,
+ required this.onTap,
+ this.pressedScale = 0.95,
+ this.semanticLabel,
+ this.behavior = HitTestBehavior.opaque,
+ });
+
+ final Widget child;
+ final VoidCallback? onTap;
+ final double pressedScale;
+
+ /// 스크린리더용 이름. 없으면 자식의 텍스트를 그대로 읽는다.
+ final String? semanticLabel;
+ final HitTestBehavior behavior;
+
+ @override
+ State createState() => _PressableState();
+}
+
+class _PressableState extends State {
+ bool _pressed = false;
+
+ void _setPressed(bool value) {
+ if (_pressed == value) return;
+ setState(() => _pressed = value);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final enabled = widget.onTap != null;
+ return Semantics(
+ button: true,
+ enabled: enabled,
+ label: widget.semanticLabel,
+ excludeSemantics: widget.semanticLabel != null,
+ child: MouseRegion(
+ cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer,
+ child: GestureDetector(
+ behavior: widget.behavior,
+ onTap: widget.onTap,
+ onTapDown: enabled ? (_) => _setPressed(true) : null,
+ onTapUp: enabled ? (_) => _setPressed(false) : null,
+ onTapCancel: enabled ? () => _setPressed(false) : null,
+ child: AnimatedScale(
+ scale: _pressed ? widget.pressedScale : 1,
+ duration: AppDurations.press,
+ curve: Curves.easeOut,
+ child: widget.child,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart
new file mode 100644
index 0000000..e8332ab
--- /dev/null
+++ b/flutter/lib/shared/widgets/rup_button.dart
@@ -0,0 +1,108 @@
+import 'package:flutter/material.dart';
+import 'package:tma_app/core/constants/app_colors.dart';
+import 'package:tma_app/core/constants/app_sizes.dart';
+import 'package:tma_app/shared/widgets/pressable.dart';
+
+/// 시안 `.rup-btn` 변형
+enum RupButtonVariant {
+ /// 주황 채움 (.rup-btn--primary)
+ primary,
+
+ /// 흰 바탕 + 선 (.rup-btn--ghost)
+ ghost,
+
+ /// 연주황 바탕 (.rup-btn--soft)
+ soft,
+
+ /// 주황 화면 위의 흰 버튼 (인트로·온보딩 CTA)
+ light,
+}
+
+/// 시안의 pill 버튼. 기본은 가로를 꽉 채운다(.rup-btn--block).
+class RupButton extends StatelessWidget {
+ const RupButton({
+ super.key,
+ required this.label,
+ required this.onPressed,
+ this.variant = RupButtonVariant.primary,
+ this.large = false,
+ this.expand = true,
+ this.height,
+ this.leading,
+ });
+
+ final String label;
+ final VoidCallback? onPressed;
+ final RupButtonVariant variant;
+
+ /// .rup-btn--lg (글자 16, 세로 여백 16)
+ final bool large;
+
+ /// 가로 채움 여부
+ final bool expand;
+
+ /// 높이를 시안 값으로 고정해야 하는 자리(인트로 CTA 46 등)에서만 지정
+ final double? height;
+ final Widget? leading;
+
+ @override
+ Widget build(BuildContext context) {
+ final (bg, fg, border) = switch (variant) {
+ RupButtonVariant.primary => (
+ AppColors.primary,
+ Colors.white,
+ AppColors.primaryBorder,
+ ),
+ RupButtonVariant.ghost => (AppColors.surface, AppColors.ink, AppColors.line),
+ RupButtonVariant.soft => (
+ AppColors.primary50,
+ AppColors.primary600,
+ AppColors.primary100,
+ ),
+ RupButtonVariant.light => (AppColors.offWhite, AppColors.ink, null),
+ };
+ final textStyle = Theme.of(context).textTheme.labelLarge!.copyWith(
+ color: fg,
+ fontSize: large ? 16 : null,
+ );
+ final verticalPad = large ? AppSpacing.lg : 13.0;
+
+ final content = Row(
+ mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (leading != null) ...[leading!, const SizedBox(width: 7)],
+ Flexible(
+ child: Text(
+ label,
+ style: textStyle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ );
+
+ return Opacity(
+ opacity: onPressed == null ? 0.5 : 1,
+ child: Pressable(
+ onTap: onPressed,
+ child: Container(
+ height: height,
+ width: expand ? double.infinity : null,
+ alignment: Alignment.center,
+ padding: EdgeInsets.symmetric(
+ horizontal: 18,
+ vertical: height == null ? verticalPad : 0,
+ ),
+ decoration: BoxDecoration(
+ color: bg,
+ borderRadius: BorderRadius.circular(AppRadius.pill),
+ border: border == null ? null : Border.all(color: border, width: 1.5),
+ ),
+ child: content,
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift
index 67d652c..ee71999 100644
--- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -6,7 +6,9 @@ import FlutterMacOS
import Foundation
import flutter_secure_storage_darwin
+import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
+ SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}
diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock
index 813608b..ef237e3 100644
--- a/flutter/pubspec.lock
+++ b/flutter/pubspec.lock
@@ -230,6 +230,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
+ flutter_svg:
+ dependency: "direct main"
+ description:
+ name: flutter_svg
+ sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -256,6 +264,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
+ go_router:
+ dependency: "direct main"
+ description:
+ name: go_router
+ sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea
+ url: "https://pub.dev"
+ source: hosted
+ version: "17.5.0"
hooks:
dependency: transitive
description:
@@ -264,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
+ http:
+ dependency: transitive
+ description:
+ name: http
+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -416,6 +440,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
+ path_parsing:
+ dependency: transitive
+ description:
+ name: path_parsing
+ sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
path_provider:
dependency: transitive
description:
@@ -464,6 +496,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
platform:
dependency: transitive
description:
@@ -504,6 +544,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
+ shared_preferences:
+ dependency: "direct main"
+ description:
+ name: shared_preferences
+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.5"
+ shared_preferences_android:
+ dependency: transitive
+ description:
+ name: shared_preferences_android
+ sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.23"
+ shared_preferences_foundation:
+ dependency: transitive
+ description:
+ name: shared_preferences_foundation
+ sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.7"
+ shared_preferences_linux:
+ dependency: transitive
+ description:
+ name: shared_preferences_linux
+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_platform_interface:
+ dependency: transitive
+ description:
+ name: shared_preferences_platform_interface
+ sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ shared_preferences_web:
+ dependency: transitive
+ description:
+ name: shared_preferences_web
+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ shared_preferences_windows:
+ dependency: transitive
+ description:
+ name: shared_preferences_windows
+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -637,6 +733,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ vector_graphics:
+ dependency: transitive
+ description:
+ name: vector_graphics
+ sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.3"
+ vector_graphics_codec:
+ dependency: transitive
+ description:
+ name: vector_graphics_codec
+ sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.13"
+ vector_graphics_compiler:
+ dependency: transitive
+ description:
+ name: vector_graphics_compiler
+ sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
vector_math:
dependency: transitive
description:
@@ -709,6 +829,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
yaml:
dependency: transitive
description:
diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml
index 9f7a4db..7af0604 100644
--- a/flutter/pubspec.yaml
+++ b/flutter/pubspec.yaml
@@ -38,6 +38,9 @@ dependencies:
flutter_dotenv: ^5.2.1
flutter_riverpod: ^3.3.1
flutter_secure_storage: ^10.0.0
+ go_router: ^17.5.0
+ flutter_svg: ^2.3.0
+ shared_preferences: ^2.5.5
dev_dependencies:
flutter_test:
@@ -64,29 +67,42 @@ flutter:
# 런타임 환경변수 — flutter_dotenv 로 로드
assets:
- .env
+ # 시안(.design/assets) 에서 가져온 이미지. 경로 상수는 lib/core/constants/app_assets.dart
+ - assets/images/splash/
+ - assets/images/onboarding/
+ - assets/images/login/
+ - assets/svg/intro/
+ - assets/svg/login/
- # An image asset can refer to one or more resolution-specific "variants", see
- # https://flutter.dev/to/resolution-aware-images
-
- # For details regarding adding assets from package dependencies, see
- # https://flutter.dev/to/asset-from-package
-
- # To add custom fonts to your application, add a fonts section here,
- # in this "flutter" section. Each entry in this list should have a
- # "family" key with the font family name, and a "fonts" key with a
- # list giving the asset and other descriptors for the font. For
- # example:
- # fonts:
- # - family: Schyler
- # fonts:
- # - asset: fonts/Schyler-Regular.ttf
- # - asset: fonts/Schyler-Italic.ttf
- # style: italic
- # - family: Trajan Pro
- # fonts:
- # - asset: fonts/TrajanPro.ttf
- # - asset: fonts/TrajanPro_Bold.ttf
- # weight: 700
- #
- # For details regarding fonts from package dependencies,
- # see https://flutter.dev/to/font-from-package
+ # 폰트 — 출처·라이선스는 assets/fonts/README.md
+ fonts:
+ # 본문(UI) 서체 — 시안의 --font-ui
+ - family: SUITE
+ fonts:
+ - asset: assets/fonts/SUITE-Regular.ttf
+ weight: 400
+ - asset: assets/fonts/SUITE-Medium.ttf
+ weight: 500
+ - asset: assets/fonts/SUITE-SemiBold.ttf
+ weight: 600
+ - asset: assets/fonts/SUITE-Bold.ttf
+ weight: 700
+ - asset: assets/fonts/SUITE-ExtraBold.ttf
+ weight: 800
+ # 제목 서체 — 시안의 --font-display.
+ # 시안은 Bold 컷을 normal 로 등록해 두고 800 을 요청해 브라우저가 한 번 더 굵게 합성한다.
+ # 같은 효과를 내려고 Bold 컷을 400 으로 등록한다 (굵은 weight 요청 시 엔진이 합성).
+ - family: ChungbukBold
+ fonts:
+ - asset: assets/fonts/ChungbukNationalUniversity-Bold.ttf
+ # 펫 명함 이름 등 일반 굵기가 필요한 자리
+ - family: Chungbuk
+ fonts:
+ - asset: assets/fonts/ChungbukNationalUniversity-Regular.ttf
+ weight: 400
+ - asset: assets/fonts/ChungbukNationalUniversity-Bold.ttf
+ weight: 700
+ # 인트로 손글씨 문구 (Right Use to Pet)
+ - family: UhBeeKeongKeong
+ fonts:
+ - asset: assets/fonts/UhBeeKeongKeong.ttf
diff --git a/flutter/test/keyframes_test.dart b/flutter/test/keyframes_test.dart
new file mode 100644
index 0000000..b73a6af
--- /dev/null
+++ b/flutter/test/keyframes_test.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/animation.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:tma_app/core/utils/keyframes.dart';
+
+void main() {
+ group('segmentProgress', () {
+ test('시작 전은 0, 끝난 뒤는 1 로 고정된다 (fill-mode: both)', () {
+ expect(segmentProgress(0.1, 0.5, 1), 0);
+ expect(segmentProgress(2, 0.5, 1), 1);
+ });
+
+ test('구간 중간이면 경과 비율을 돌려준다', () {
+ expect(segmentProgress(1, 0.5, 1), closeTo(0.5, 1e-9));
+ });
+ });
+
+ group('keyframeValue', () {
+ const stops = [0.0, 0.5, 1.0];
+ const values = [0.0, 10.0, 4.0];
+
+ test('키프레임 위치에서는 그 값을 그대로 돌려준다', () {
+ expect(keyframeValue(0, stops, values), 0);
+ expect(keyframeValue(0.5, stops, values), 10);
+ expect(keyframeValue(1, stops, values), 4);
+ });
+
+ test('구간 안에서는 선형 보간한다', () {
+ expect(keyframeValue(0.25, stops, values), closeTo(5, 1e-9));
+ expect(keyframeValue(0.75, stops, values), closeTo(7, 1e-9));
+ });
+
+ test('곡선은 전체가 아니라 구간마다 적용된다', () {
+ // 두 번째 구간의 시작점에서는 곡선과 무관하게 구간 시작 값이어야 한다
+ expect(keyframeValue(0.5, stops, values, curve: Curves.easeIn), 10);
+ // 구간 절반 지점 = 해당 구간 local 0.5 에 곡선 적용
+ final expected = 10 + (4 - 10) * Curves.easeIn.transform(0.5);
+ expect(keyframeValue(0.75, stops, values, curve: Curves.easeIn), closeTo(expected, 1e-9));
+ });
+
+ test('범위를 벗어나면 양 끝 값으로 고정된다', () {
+ expect(keyframeValue(-1, stops, values), 0);
+ expect(keyframeValue(2, stops, values), 4);
+ });
+ });
+}