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( + '' + '${name.paths}', + 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( + '${glyph.paths}', + 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); + }); + }); +}