From 2371dff023ed52dff54d6053aceb4afb642c4060 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 18:54:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=A7=84=EC=9E=85=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84(=EC=8A=A4=ED=94=8C=EB=9E=98=EC=8B=9C=E2=86=92?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A1=9C=E2=86=92=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=E2=86=92=EC=98=A8=EB=B3=B4=EB=94=A9)=EA=B3=BC=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=20=ED=83=AD=20=EC=85=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시안의 첫 진입 흐름을 순서대로 옮겼다. - 스플래시: 이미지 디코딩 후 등장 애니메이션 재생, 2.6초 뒤 진입 단계에 맞는 화면으로 이동 - 인트로·온보딩은 설치 후 1회만 노출, 로그아웃 시 로그인으로 복귀 (EntryState → 라우터 redirect) - 온보딩 일러스트는 블러·마스크 때문에 레이어별 PNG 로 래스터화하고 움직임은 Flutter 로 재현 (하단 안내문·시작하기 버튼은 비율이 다른 기기에서 잘리지 않도록 위젯으로 분리) - 로그인은 DemoAuthRepository 로 임시 처리 — 소셜 SDK 앱 키와 백엔드 인증 API 연동 시 교체 - 하단 탭 4개 셸(StatefulShellRoute), 탭 화면은 흐름 순서대로 교체할 자리만 둔다 Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/lib/app.dart | 19 ++ flutter/lib/core/router/app_router.dart | 106 +++++++ flutter/lib/core/router/app_routes.dart | 43 +++ .../data/repositories/auth_repository.dart | 43 +++ .../features/auth/domain/social_provider.dart | 2 + .../auth/presentation/pages/login_page.dart | 187 +++++++++++ .../widgets/social_login_button.dart | 123 ++++++++ .../features/entry/data/entry_repository.dart | 25 ++ .../features/entry/domain/entry_state.dart | 29 ++ .../providers/entry_provider.dart | 49 +++ .../intro/presentation/pages/intro_page.dart | 113 +++++++ .../presentation/pages/onboarding_page.dart | 121 +++++++ .../widgets/onboarding_scene.dart | 179 +++++++++++ .../lib/features/shell/domain/app_tab.dart | 20 ++ .../shell/presentation/pages/main_shell.dart | 32 ++ .../pages/tab_placeholder_page.dart | 39 +++ .../presentation/widgets/rup_tab_bar.dart | 119 +++++++ .../presentation/pages/splash_page.dart | 296 ++++++++++++++++++ flutter/lib/main.dart | 66 +--- flutter/test/entry_redirect_test.dart | 59 ++++ flutter/test/widget_test.dart | 113 ++++++- 21 files changed, 1726 insertions(+), 57 deletions(-) create mode 100644 flutter/lib/app.dart create mode 100644 flutter/lib/core/router/app_router.dart create mode 100644 flutter/lib/core/router/app_routes.dart create mode 100644 flutter/lib/features/auth/data/repositories/auth_repository.dart create mode 100644 flutter/lib/features/auth/domain/social_provider.dart create mode 100644 flutter/lib/features/auth/presentation/pages/login_page.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/social_login_button.dart create mode 100644 flutter/lib/features/entry/data/entry_repository.dart create mode 100644 flutter/lib/features/entry/domain/entry_state.dart create mode 100644 flutter/lib/features/entry/presentation/providers/entry_provider.dart create mode 100644 flutter/lib/features/intro/presentation/pages/intro_page.dart create mode 100644 flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart create mode 100644 flutter/lib/features/onboarding/presentation/widgets/onboarding_scene.dart create mode 100644 flutter/lib/features/shell/domain/app_tab.dart create mode 100644 flutter/lib/features/shell/presentation/pages/main_shell.dart create mode 100644 flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart create mode 100644 flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart create mode 100644 flutter/lib/features/splash/presentation/pages/splash_page.dart create mode 100644 flutter/test/entry_redirect_test.dart diff --git a/flutter/lib/app.dart b/flutter/lib/app.dart new file mode 100644 index 0000000..51bca10 --- /dev/null +++ b/flutter/lib/app.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/router/app_router.dart'; +import 'package:tma_app/core/theme/app_theme.dart'; + +/// 앱 루트 — 테마와 라우터를 연결한다. +class RupApp extends ConsumerWidget { + const RupApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp.router( + title: 'rup', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + routerConfig: ref.watch(appRouterProvider), + ); + } +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart new file mode 100644 index 0000000..f69fc0f --- /dev/null +++ b/flutter/lib/core/router/app_router.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; +import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; +import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; +import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:tma_app/features/shell/presentation/pages/main_shell.dart'; +import 'package:tma_app/features/shell/presentation/pages/tab_placeholder_page.dart'; +import 'package:tma_app/features/splash/presentation/pages/splash_page.dart'; + +/// 진입 화면 전환 — 시안은 화면을 겹쳐 두고 페이드로 넘긴다 +CustomTransitionPage _fadePage(GoRouterState state, Widget child) { + return CustomTransitionPage( + key: state.pageKey, + child: child, + transitionDuration: AppDurations.fade, + reverseTransitionDuration: AppDurations.fade, + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ); +} + +final appRouterProvider = Provider((ref) { + // 진입 상태가 바뀌면(로그인·온보딩 완료·로그아웃) redirect 를 다시 평가한다 + final refresh = ValueNotifier(0); + ref.listen(entryProvider, (previous, next) => refresh.value++); + + final router = GoRouter( + initialLocation: AppRoutes.splash, + refreshListenable: refresh, + redirect: (context, state) => AppRoutes.redirectFor( + ref.read(entryProvider).stage, + state.matchedLocation, + ), + routes: [ + GoRoute( + path: AppRoutes.splash, + pageBuilder: (context, state) => _fadePage(state, const SplashPage()), + ), + GoRoute( + path: AppRoutes.intro, + pageBuilder: (context, state) => _fadePage(state, const IntroPage()), + ), + GoRoute( + path: AppRoutes.login, + pageBuilder: (context, state) => _fadePage(state, const LoginPage()), + ), + GoRoute( + path: AppRoutes.onboarding, + pageBuilder: (context, state) => + _fadePage(state, const OnboardingPage()), + ), + StatefulShellRoute.indexedStack( + pageBuilder: (context, state, navigationShell) => + _fadePage(state, MainShell(navigationShell: navigationShell)), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.home, + builder: (context, state) => + const TabPlaceholderPage(title: '홈'), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.records, + builder: (context, state) => + const TabPlaceholderPage(title: '일정·기록'), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.petCards, + builder: (context, state) => + const TabPlaceholderPage(title: '펫 명함'), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.myPage, + builder: (context, state) => + const TabPlaceholderPage(title: '마이페이지'), + ), + ], + ), + ], + ), + ], + ); + + ref.onDispose(() { + router.dispose(); + refresh.dispose(); + }); + return router; +}); diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart new file mode 100644 index 0000000..007ee6f --- /dev/null +++ b/flutter/lib/core/router/app_routes.dart @@ -0,0 +1,43 @@ +import 'package:tma_app/features/entry/domain/entry_state.dart'; + +/// 라우트 경로 상수 +abstract final class AppRoutes { + static const String splash = '/splash'; + static const String intro = '/intro'; + static const String login = '/login'; + static const String onboarding = '/onboarding'; + + // 하단 탭 + static const String home = '/home'; + static const String records = '/records'; + static const String petCards = '/pet-cards'; + static const String myPage = '/mypage'; + + /// 로그인 전에만 머무를 수 있는 화면 + static const Set _signedOutOnly = {intro, login}; + + /// 진입 단계별 첫 화면 + static String forStage(EntryStage stage) => switch (stage) { + EntryStage.intro => intro, + EntryStage.login => login, + EntryStage.onboarding => onboarding, + EntryStage.app => home, + }; + + /// 진입 단계에 맞지 않는 경로면 보내야 할 경로를, 그대로 두면 null 을 돌려준다. + /// 스플래시는 스스로 다음 화면을 정하므로 건드리지 않는다. + static String? redirectFor(EntryStage stage, String location) { + if (location == splash) return null; + switch (stage) { + case EntryStage.intro: + case EntryStage.login: + // 로그인 전에는 인트로 ↔ 로그인 사이만 오갈 수 있다 (로그인의 뒤로가기 = 인트로) + return _signedOutOnly.contains(location) ? null : forStage(stage); + case EntryStage.onboarding: + return location == onboarding ? null : onboarding; + case EntryStage.app: + final inEntryFlow = _signedOutOnly.contains(location) || location == onboarding; + return inEntryFlow ? home : null; + } + } +} diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart new file mode 100644 index 0000000..9467e46 --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/auth/domain/social_provider.dart'; + +/// 로그인 세션 관리 계약. +/// 실제 구현은 소셜 SDK 로 받은 토큰을 백엔드 인증 API 에 넘기고, +/// 발급받은 JWT 를 flutter_secure_storage(kJwtTokenKey)에 저장한다. +abstract interface class AuthRepository { + /// 저장된 세션이 있는지 (앱 시작 시 진입 화면 결정에 쓴다) + bool get hasSession; + + Future signIn(SocialProvider provider); + + Future signOut(); +} + +const String _kDemoSessionKey = 'auth.demoSession'; + +/// 임시 구현 — 백엔드 인증 API 와 소셜 SDK(앱 키 필요)가 준비되기 전까지 +/// 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다. +class DemoAuthRepository implements AuthRepository { + DemoAuthRepository(this._prefs); + + final SharedPreferences _prefs; + + @override + bool get hasSession => _prefs.containsKey(_kDemoSessionKey); + + @override + Future signIn(SocialProvider provider) async { + await _prefs.setString(_kDemoSessionKey, provider.name); + } + + @override + Future signOut() async { + await _prefs.remove(_kDemoSessionKey); + } +} + +final authRepositoryProvider = Provider((ref) { + return DemoAuthRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/auth/domain/social_provider.dart b/flutter/lib/features/auth/domain/social_provider.dart new file mode 100644 index 0000000..8399ff8 --- /dev/null +++ b/flutter/lib/features/auth/domain/social_provider.dart @@ -0,0 +1,2 @@ +/// 지원하는 소셜 로그인 제공자 +enum SocialProvider { naver, kakao, google, apple } diff --git a/flutter/lib/features/auth/presentation/pages/login_page.dart b/flutter/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..a8bcb9f --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,187 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/api/api_exception.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/features/auth/domain/social_provider.dart'; +import 'package:tma_app/features/auth/presentation/widgets/social_login_button.dart'; +import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; +import 'package:tma_app/shared/widgets/design_art_box.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; + +/// 일러스트 묶음 — 낚싯대(50,358) · 고양이(218,435) +const Size _kArtSize = Size(327, 179); + +/// 인사말과 버튼 사이 남는 공간에서 묶음이 놓이는 위치 (시안 좌표에서 역산) +const Alignment _kArtAlignment = Alignment(.33, .68); + +/// 시안: 버튼 묶음은 화면 아래에서 76 (홈 인디케이터 34 포함) +const double _kButtonsBottomGap = 42; +const double _kButtonGap = 15; + +/// 로그인 — 소셜 계정으로 가입/로그인한다. +/// 성공하면 진입 상태가 바뀌고 라우터가 온보딩(첫 로그인) 또는 홈으로 보낸다. +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + /// 진행 중인 로그인 — 중복 탭을 막는다 + SocialProvider? _pending; + + Future _signIn(SocialProvider provider) async { + if (_pending != null) return; + setState(() => _pending = provider); + try { + await ref.read(entryProvider.notifier).signIn(provider); + } on DioException catch (e) { + // 문구는 에러 사전(ApiException.message)에서만 온다 — 여기서 새로 만들지 않는다. + // 전역 알림(토스트)을 도입하면 그쪽에서 띄우고, 화면은 code 로만 분기한다. + if (e.error is! ApiException) rethrow; + } finally { + if (mounted) setState(() => _pending = null); + } + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark, + child: Scaffold( + backgroundColor: AppColors.surface, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _LoginNavBar(onBack: () => context.go(AppRoutes.intro)), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xxl, + AppSpacing.xl, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SvgPicture.asset(AppAssets.loginPaw, width: 22, height: 22), + const SizedBox(width: 9), + Text('반가워요, 집사님!', style: textTheme.displaySmall), + ], + ), + const SizedBox(height: 14), + Text( + '계속 이용하시려면 로그인이 필요해요.\n3초면 충분해요!', + style: textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ), + const Expanded( + child: Align(alignment: _kArtAlignment, child: _LoginArt()), + ), + SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: AppSpacing.xl), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.xl, + _kButtonsBottomGap, + ), + child: Column( + children: [ + for (final provider in SocialProvider.values) ...[ + if (provider != SocialProvider.values.first) + const SizedBox(height: _kButtonGap), + SocialLoginButton( + provider: provider, + onPressed: _pending == null ? () => _signIn(provider) : null, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 상단 바 — 뒤로(인트로) + 가운데 제목 +class _LoginNavBar extends StatelessWidget { + const _LoginNavBar({required this.onBack}); + + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: AppControlSize.navBarHeight, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: AppSpacing.md, + child: Pressable( + onTap: onBack, + pressedScale: .9, + semanticLabel: '뒤로', + child: SizedBox.square( + dimension: 36, + child: Center( + child: SvgPicture.asset(AppAssets.loginBack, width: 12, height: 12), + ), + ), + ), + ), + Text('로그인', style: Theme.of(context).textTheme.titleSmall), + ], + ), + ); + } +} + +class _LoginArt extends StatelessWidget { + const _LoginArt(); + + @override + Widget build(BuildContext context) { + return DesignArtBox( + designSize: _kArtSize, + children: [ + Positioned( + left: 0, + top: 0, + width: 143, + height: 103, + child: SvgPicture.asset(AppAssets.loginWand), + ), + Positioned( + left: 168, + top: 77, + width: 159, + height: 102, + child: Image.asset(AppAssets.loginCat, fit: BoxFit.contain), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/social_login_button.dart b/flutter/lib/features/auth/presentation/widgets/social_login_button.dart new file mode 100644 index 0000000..5055abd --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/social_login_button.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/features/auth/domain/social_provider.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; + +/// 시안 GoogleMark — 구글 4색 공식 마크 +const String _kGoogleMark = ''' + +'''; + +/// 로고 마크를 버튼 왼쪽 17 지점에 세로 가운데로 둔다 (.rup-login__mark) +const double _kMarkInset = 17; +const double _kMarkSize = 22; + +/// 제공자별 버튼 모양 — 시안 .rup-login__btn--{naver,kakao,google,apple} +class _SocialStyle { + const _SocialStyle({ + required this.label, + required this.background, + required this.foreground, + required this.mark, + this.border, + }); + + final String label; + final Color background; + final Color foreground; + final Color? border; + final Widget mark; +} + +_SocialStyle _styleOf(SocialProvider provider) { + return switch (provider) { + SocialProvider.naver => _SocialStyle( + label: '네이버로 로그인', + background: AppColors.naver, + foreground: Colors.white, + mark: SvgPicture.asset(AppAssets.markNaver, width: 17.1, height: 15), + ), + SocialProvider.kakao => _SocialStyle( + label: '카카오로 3초만에 시작하기', + background: AppColors.kakao, + foreground: AppColors.ink, + mark: SvgPicture.asset(AppAssets.markKakao, width: 24, height: 20.63), + ), + SocialProvider.google => _SocialStyle( + label: '구글로 로그인', + background: Colors.white, + foreground: AppColors.ink, + border: AppColors.googleBorder, + mark: SvgPicture.string( + _kGoogleMark, + width: _kMarkSize, + height: _kMarkSize, + ), + ), + SocialProvider.apple => _SocialStyle( + label: 'Apple로 로그인', + background: AppColors.apple, + foreground: Colors.white, + mark: Image.asset( + AppAssets.markApple, + width: _kMarkSize, + height: _kMarkSize, + ), + ), + }; +} + +/// 소셜 로그인 버튼 — 높이 46 pill, 제공자 브랜드색 +class SocialLoginButton extends StatelessWidget { + const SocialLoginButton({ + super.key, + required this.provider, + required this.onPressed, + }); + + final SocialProvider provider; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final style = _styleOf(provider); + return Pressable( + onTap: onPressed, + pressedScale: .98, + semanticLabel: style.label, + child: Container( + height: AppControlSize.buttonHeight, + width: double.infinity, + decoration: BoxDecoration( + color: style.background, + borderRadius: BorderRadius.circular(AppControlSize.buttonHeight / 2), + border: style.border == null + ? null + : Border.all(color: style.border!), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: _kMarkInset, + top: 0, + bottom: 0, + child: Center(child: style.mark), + ), + Text( + style.label, + maxLines: 1, + style: Theme.of(context) + .textTheme + .labelLarge! + .copyWith(color: style.foreground), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/entry/data/entry_repository.dart b/flutter/lib/features/entry/data/entry_repository.dart new file mode 100644 index 0000000..adda5c4 --- /dev/null +++ b/flutter/lib/features/entry/data/entry_repository.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; + +const String _kIntroSeenKey = 'entry.introSeen'; +const String _kOnboardingSeenKey = 'entry.onboardingSeen'; + +/// 설치 후 1회만 보여 주는 화면(인트로·온보딩)의 확인 여부를 기기에 저장한다. +class EntryRepository { + EntryRepository(this._prefs); + + final SharedPreferences _prefs; + + bool get introSeen => _prefs.getBool(_kIntroSeenKey) ?? false; + + bool get onboardingSeen => _prefs.getBool(_kOnboardingSeenKey) ?? false; + + Future markIntroSeen() => _prefs.setBool(_kIntroSeenKey, true); + + Future markOnboardingSeen() => _prefs.setBool(_kOnboardingSeenKey, true); +} + +final entryRepositoryProvider = Provider((ref) { + return EntryRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/entry/domain/entry_state.dart b/flutter/lib/features/entry/domain/entry_state.dart new file mode 100644 index 0000000..8a38456 --- /dev/null +++ b/flutter/lib/features/entry/domain/entry_state.dart @@ -0,0 +1,29 @@ +/// 진입 단계 — 설치 후 첫 진입: 스플래시 → 인트로 → 로그인 → 온보딩 → 앱 +enum EntryStage { intro, login, onboarding, app } + +/// 진입 흐름 상태. 세 플래그로 지금 보여 줄 단계가 정해진다. +class EntryState { + const EntryState({ + required this.introSeen, + required this.signedIn, + required this.onboardingSeen, + }); + + final bool introSeen; + final bool signedIn; + final bool onboardingSeen; + + EntryStage get stage { + if (!signedIn) return introSeen ? EntryStage.login : EntryStage.intro; + if (!onboardingSeen) return EntryStage.onboarding; + return EntryStage.app; + } + + EntryState copyWith({bool? introSeen, bool? signedIn, bool? onboardingSeen}) { + return EntryState( + introSeen: introSeen ?? this.introSeen, + signedIn: signedIn ?? this.signedIn, + onboardingSeen: onboardingSeen ?? this.onboardingSeen, + ); + } +} diff --git a/flutter/lib/features/entry/presentation/providers/entry_provider.dart b/flutter/lib/features/entry/presentation/providers/entry_provider.dart new file mode 100644 index 0000000..3df4f8f --- /dev/null +++ b/flutter/lib/features/entry/presentation/providers/entry_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:tma_app/features/auth/domain/social_provider.dart'; +import 'package:tma_app/features/entry/data/entry_repository.dart'; +import 'package:tma_app/features/entry/domain/entry_state.dart'; + +/// 진입 흐름 상태. 라우터가 이 값을 구독해 화면을 자동으로 전환한다. +class EntryNotifier extends Notifier { + EntryRepository get _entry => ref.read(entryRepositoryProvider); + AuthRepository get _auth => ref.read(authRepositoryProvider); + + @override + EntryState build() { + return EntryState( + introSeen: ref.watch(entryRepositoryProvider).introSeen, + signedIn: ref.watch(authRepositoryProvider).hasSession, + onboardingSeen: ref.watch(entryRepositoryProvider).onboardingSeen, + ); + } + + Future completeIntro() async { + await _entry.markIntroSeen(); + if (!ref.mounted) return; + state = state.copyWith(introSeen: true); + } + + Future signIn(SocialProvider provider) async { + await _auth.signIn(provider); + if (!ref.mounted) return; + state = state.copyWith(signedIn: true); + } + + Future completeOnboarding() async { + await _entry.markOnboardingSeen(); + if (!ref.mounted) return; + state = state.copyWith(onboardingSeen: true); + } + + /// 로그아웃하면 로그인 화면으로 돌아간다 (인트로·온보딩은 다시 보이지 않는다) + Future signOut() async { + await _auth.signOut(); + if (!ref.mounted) return; + state = state.copyWith(signedIn: false); + } +} + +final entryProvider = NotifierProvider( + EntryNotifier.new, +); diff --git a/flutter/lib/features/intro/presentation/pages/intro_page.dart b/flutter/lib/features/intro/presentation/pages/intro_page.dart new file mode 100644 index 0000000..4970b6b --- /dev/null +++ b/flutter/lib/features/intro/presentation/pages/intro_page.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/core/theme/app_typography.dart'; +import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; +import 'package:tma_app/shared/widgets/design_art_box.dart'; +import 'package:tma_app/shared/widgets/rup_button.dart'; + +/// 일러스트 묶음의 시안 크기·위치 — 문구(50,218) · 강아지(213,350) · 공(115,486) +const Size _kArtSize = Size(313, 321); + +/// 묶음이 CTA 위 남는 공간에서 놓이는 위치 (시안 좌표에서 역산) +const Alignment _kArtAlignment = Alignment(.12, .02); + +/// 시안: CTA 는 화면 아래에서 80 (홈 인디케이터 34 포함) +const double _kCtaBottomGap = 46; + +/// 인트로 — 설치 후 첫 진입(스플래시 다음)에 한 번 보여 준다. +class IntroPage extends ConsumerWidget { + const IntroPage({super.key}); + + void _start(BuildContext context, WidgetRef ref) { + unawaited(ref.read(entryProvider.notifier).completeIntro()); + context.go(AppRoutes.login); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: AppColors.primary, + body: Column( + children: [ + const Expanded( + child: Align( + alignment: _kArtAlignment, + child: _IntroArt(), + ), + ), + SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: AppSpacing.xl), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + 0, + AppSpacing.xl, + _kCtaBottomGap, + ), + child: RupButton( + label: '시작하기', + variant: RupButtonVariant.light, + height: AppControlSize.buttonHeight, + onPressed: () => _start(context, ref), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _IntroArt extends StatelessWidget { + const _IntroArt(); + + @override + Widget build(BuildContext context) { + return DesignArtBox( + designSize: _kArtSize, + children: [ + const Positioned( + left: 0, + top: 0, + child: Text( + 'Right\nUse to\nPet', + style: TextStyle( + fontFamily: AppFonts.handwriting, + fontSize: 48, + height: 76 / 48, + letterSpacing: .5, + color: AppColors.offWhite, + ), + ), + ), + Positioned( + left: 163, + top: 132, + width: 150, + height: 125, + child: SvgPicture.asset(AppAssets.introDog), + ), + Positioned( + left: 65, + top: 268, + width: 51, + height: 53, + child: SvgPicture.asset(AppAssets.introBall), + ), + ], + ); + } +} diff --git a/flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart b/flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart new file mode 100644 index 0000000..a63b126 --- /dev/null +++ b/flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; +import 'package:tma_app/features/onboarding/presentation/widgets/onboarding_scene.dart'; +import 'package:tma_app/shared/widgets/design_art_box.dart'; +import 'package:tma_app/shared/widgets/rup_button.dart'; + +/// 온보딩 CTA 높이 — 시안 onb-4 의 버튼(372×42, rx 21) +const double _kCtaHeight = 42; + +/// 온보딩 — 가입 후 1회. 화면 아무 곳이나 누르면 다음 장으로, 마지막 장에서는 앱으로 들어간다. +/// 완료하면 진입 상태가 바뀌고 라우터가 홈으로 보낸다. +class OnboardingPage extends ConsumerStatefulWidget { + const OnboardingPage({super.key}); + + @override + ConsumerState createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends ConsumerState { + int _index = 0; + bool _finishing = false; + + bool get _isLast => _index == OnboardingSlide.values.length - 1; + + void _next() { + if (_isLast) { + _finish(); + } else { + setState(() => _index++); + } + } + + void _finish() { + if (_finishing) return; + _finishing = true; + unawaited(ref.read(entryProvider.notifier).completeOnboarding()); + } + + @override + Widget build(BuildContext context) { + final slide = OnboardingSlide.values[_index]; + return AnnotatedRegion( + // 마지막 장만 상단이 주황이다 + value: _isLast ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + child: Scaffold( + backgroundColor: AppColors.primary, + body: Semantics( + label: slide.semanticText, + hint: _isLast ? null : '화면을 누르면 다음으로 넘어갑니다', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _next, + child: Stack( + fit: StackFit.expand, + children: [ + DesignCover( + designSize: kOnboardingCanvas, + // 장이 바뀌면 등장 애니메이션을 처음부터 다시 재생한다 + child: OnboardingScene(key: ValueKey(slide), slide: slide), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: AppSpacing.md), + child: _isLast + ? Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + 0, + AppSpacing.xl, + AppSpacing.sm, + ), + child: RupButton( + label: '시작하기', + variant: RupButtonVariant.light, + height: _kCtaHeight, + onPressed: _finish, + ), + ) + : _Hint(color: slide.hintColor), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _Hint extends StatelessWidget { + const _Hint({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Text( + '화면을 눌러 다음으로 이동해 주세요.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: color, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + ); + } +} diff --git a/flutter/lib/features/onboarding/presentation/widgets/onboarding_scene.dart b/flutter/lib/features/onboarding/presentation/widgets/onboarding_scene.dart new file mode 100644 index 0000000..db56ed5 --- /dev/null +++ b/flutter/lib/features/onboarding/presentation/widgets/onboarding_scene.dart @@ -0,0 +1,179 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/utils/keyframes.dart'; + +/// 온보딩 캔버스 — 시안 onb-*.svg 의 크기 +const Size kOnboardingCanvas = Size(412, 892); + +/// 온보딩 네 장. 문구는 그림 안에 있으므로 스크린리더용 문장을 따로 둔다. +enum OnboardingSlide { + greeting('안녕하세요! 집사님의 반려생활 친구 루피에요.', AppColors.onboardingHint), + healing('귀엽고 이쁜 우리 아이, 보기만 해도 힐링되지만', AppColors.onboardingHint), + helper('가끔은 케어를 도와줄 든든한 조력자가 필요할 때도 있죠.', Colors.white), + start('루피가 도와드릴께요! 함께 더 편하고 재미있는 반려생활을 시작해봐요!', Colors.white); + + const OnboardingSlide(this.semanticText, this.hintColor); + + final String semanticText; + + /// 하단 안내문 색 — 시안: 1·2장 #C8C8C8, 3장 흰색 (4장은 버튼) + final Color hintColor; +} + +// ── 시안 styles.css 의 온보딩 애니메이션 값 ──────────────────────── +const Cubic _kSlideCurve = Cubic(.18, .86, .24, 1); +const Cubic _kHeroCurve = Cubic(.16, .84, .22, 1); +const Cubic _kGrowCurve = Cubic(.2, .9, .26, 1.06); + +/// 반짝임(onb-twinkle) — 2.6초 주기 무한 반복, 요소별로 시작만 다르다 +const double _kTwinklePeriod = 2.6; +const List _kTwinkleStops = [0, .14, .26, .48, .70, 1]; +const List _kTwinkleOpacity = [0, 1, .95, .2, 1, .35]; +const List _kTwinkleScale = [.15, 1.2, 1, .8, 1.08, .9]; +const List _kTwinkleRotate = [-25, 0, 0, 0, 0, 0]; + +/// 반짝임 레이어 — 자기 bbox 중심(transform-origin: 50% 50%, fill-box)을 기준으로 움직인다 +class _Sparkle { + const _Sparkle(this.asset, this.center, this.delay); + + final String asset; + final Offset center; + final double delay; +} + +const List<_Sparkle> _kSparkles = [ + _Sparkle(AppAssets.onb3Sparkle1, Offset(175.45, 478.36), .95), + _Sparkle(AppAssets.onb3Sparkle2, Offset(157.40, 461.94), 1.2), + _Sparkle(AppAssets.onb3Sparkle3, Offset(347.46, 496.81), 1.08), +]; + +/// 반짝임이 없는 장은 이 시간이 지나면 멈춘다 (가장 긴 등장 1.15초) +const double _kIntroEndSec = 1.2; + +/// 온보딩 한 장 — 배경 위에 움직이는 레이어를 시간에 맞춰 얹는다. +class OnboardingScene extends StatefulWidget { + const OnboardingScene({super.key, required this.slide}); + + final OnboardingSlide slide; + + @override + State createState() => _OnboardingSceneState(); +} + +class _OnboardingSceneState extends State + with SingleTickerProviderStateMixin { + late final Ticker _ticker = createTicker(_onTick); + double _t = 0; + + bool get _loops => widget.slide == OnboardingSlide.helper; + + @override + void initState() { + super.initState(); + _ticker.start(); + } + + void _onTick(Duration elapsed) { + setState(() => _t = elapsed.inMicroseconds / Duration.microsecondsPerSecond); + if (!_loops && _t > _kIntroEndSec) _ticker.stop(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: switch (widget.slide) { + OnboardingSlide.greeting => [ + _image(AppAssets.onb1Base), + _slideIn(AppAssets.onb1Character, const Offset(78, 112), duration: .95), + ], + OnboardingSlide.healing => [ + _image(AppAssets.onb2Base), + _slideIn(AppAssets.onb2Character, const Offset(-172, 26), duration: 1), + ], + OnboardingSlide.helper => [ + _image(AppAssets.onb3Base), + for (final s in _kSparkles) _sparkle(s), + _hero(), + ], + OnboardingSlide.start => [ + _image(AppAssets.onb4Base), + _heartGrow(), + ], + }, + ); + } + + Widget _image(String asset) => Image.asset(asset, fit: BoxFit.fill); + + /// onb-slide1/2 — 캐릭터가 [from] 만큼 떨어진 곳에서 제자리로 미끄러져 들어온다 + Widget _slideIn(String asset, Offset from, {required double duration}) { + final e = _kSlideCurve.transform(segmentProgress(_t, .1, duration)); + return Transform.translate(offset: from * (1 - e), child: _image(asset)); + } + + /// onb-twinkle + Widget _sparkle(_Sparkle s) { + // 시작 전에는 0% 키프레임(보이지 않음)에 머문다 — fill-mode: both + final local = _t < s.delay ? 0.0 : ((_t - s.delay) % _kTwinklePeriod) / _kTwinklePeriod; + double at(List values) => + keyframeValue(local, _kTwinkleStops, values, curve: Curves.easeInOut); + + final scale = at(_kTwinkleScale); + return Opacity( + opacity: at(_kTwinkleOpacity).clamp(0.0, 1.0), + child: Transform( + origin: s.center, + transform: Matrix4.identity() + ..scaleByDouble(scale, scale, 1, 1) + ..rotateZ(at(_kTwinkleRotate) * math.pi / 180), + child: _image(s.asset), + ), + ); + } + + /// onb-fly — 슈퍼맨 강아지가 아래에서 날아오른다 + Widget _hero() { + final p = segmentProgress(_t, .1, 1.05); + final dy = keyframeValue(p, const [0, 1], const [300, 0], curve: _kHeroCurve); + final opacity = keyframeValue(p, const [0, .45, 1], const [0, 1, 1], curve: _kHeroCurve); + return Positioned( + left: 0, + top: 318.5, + width: kOnboardingCanvas.width, + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, dy), + child: Image.asset(AppAssets.onb3Hero, fit: BoxFit.fitWidth), + ), + ), + ); + } + + /// onb-grow4 — 하트 속 캐릭터가 (205,700) 을 기준으로 커지며 나타난다 + Widget _heartGrow() { + final p = segmentProgress(_t, .14, .8); + final scale = keyframeValue(p, const [0, 1], const [.35, 1], curve: _kGrowCurve); + final opacity = keyframeValue(p, const [0, .4, 1], const [0, 1, 1], curve: _kGrowCurve); + return Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Transform( + origin: const Offset(205, 700), + transform: Matrix4.identity()..scaleByDouble(scale, scale, 1, 1), + child: _image(AppAssets.onb4Heart), + ), + ); + } +} diff --git a/flutter/lib/features/shell/domain/app_tab.dart b/flutter/lib/features/shell/domain/app_tab.dart new file mode 100644 index 0000000..8a7fae7 --- /dev/null +++ b/flutter/lib/features/shell/domain/app_tab.dart @@ -0,0 +1,20 @@ +import 'package:tma_app/shared/widgets/icons/rup_icon_paths.dart'; + +/// 하단 탭 — 순서가 곧 StatefulShellRoute 의 branch 인덱스다. +enum AppTab { + home('홈', icon: RupIconName.home), + records('일정·기록', icon: RupIconName.calendar), + petCards('펫 명함', glyph: RupGlyph.petCard), + myPage('마이페이지', icon: RupIconName.user); + + const AppTab(this.label, {this.icon, this.glyph}) + : assert((icon == null) != (glyph == null), '아이콘과 글리프 중 하나만 지정한다'); + + final String label; + + /// 선 아이콘 (선 굵기로 활성 상태를 표시) + final RupIconName? icon; + + /// 전용 글리프 (펫 명함) + final RupGlyph? glyph; +} diff --git a/flutter/lib/features/shell/presentation/pages/main_shell.dart b/flutter/lib/features/shell/presentation/pages/main_shell.dart new file mode 100644 index 0000000..34fc49e --- /dev/null +++ b/flutter/lib/features/shell/presentation/pages/main_shell.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/features/shell/domain/app_tab.dart'; +import 'package:tma_app/features/shell/presentation/widgets/rup_tab_bar.dart'; + +/// 로그인 후 앱 뼈대 — 탭별 화면 상태를 유지한 채 하단 탭으로 오간다. +class MainShell extends StatelessWidget { + const MainShell({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + void _select(AppTab tab) { + // 이미 보고 있는 탭을 다시 누르면 그 탭의 첫 화면으로 돌아간다 + navigationShell.goBranch( + tab.index, + initialLocation: tab.index == navigationShell.currentIndex, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + // 콘텐츠가 탭바 뒤까지 이어져 흐림 효과가 보이게 한다 + extendBody: true, + body: navigationShell, + bottomNavigationBar: RupTabBar( + current: AppTab.values[navigationShell.currentIndex], + onSelect: _select, + ), + ); + } +} diff --git a/flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart b/flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart new file mode 100644 index 0000000..f8fad5a --- /dev/null +++ b/flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; + +/// 아직 구현하지 않은 탭의 자리 — 사용자 흐름 순서대로 실제 화면으로 교체한다. +class TabPlaceholderPage extends StatelessWidget { + const TabPlaceholderPage({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.sm, + AppSpacing.xl, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.headlineSmall), + const Spacer(), + Center( + child: Text( + '준비 중인 화면이에요', + style: textTheme.bodyMedium!.copyWith(color: AppColors.muted), + ), + ), + const Spacer(), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart b/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart new file mode 100644 index 0000000..8c4af70 --- /dev/null +++ b/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart @@ -0,0 +1,119 @@ +import 'dart:ui' show ImageFilter; + +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/features/shell/domain/app_tab.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; + +/// 시안 .rup-tabbar 의 뒤 배경 흐림 정도 (blur 18px → 표준편차 9) +const double _kBlurSigma = 9; +const double _kActiveStroke = 2.2; +const double _kInactiveStroke = 1.9; + +/// 하단 탭바 — 반투명 크림 배경에 뒤 콘텐츠가 흐리게 비친다. +class RupTabBar extends StatelessWidget { + const RupTabBar({ + super.key, + required this.current, + required this.onSelect, + }); + + final AppTab current; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: _kBlurSigma, sigmaY: _kBlurSigma), + child: DecoratedBox( + decoration: const BoxDecoration( + color: AppColors.tabBarBg, + border: Border(top: BorderSide(color: AppColors.line)), + ), + child: SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: AppSpacing.md), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + 10, + AppSpacing.sm, + 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final tab in AppTab.values) + Expanded( + child: _TabItem( + tab: tab, + active: tab == current, + onTap: () => onSelect(tab), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TabItem extends StatelessWidget { + const _TabItem({required this.tab, required this.active, required this.onTap}); + + final AppTab tab; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = active ? AppColors.primary : AppColors.muted; + final icon = tab.glyph != null + ? RupGlyphIcon(tab.glyph!, size: AppControlSize.tabIcon, color: color) + : RupIcon( + tab.icon!, + size: AppControlSize.tabIcon, + stroke: active ? _kActiveStroke : _kInactiveStroke, + color: color, + ); + + return Semantics( + button: true, + selected: active, + label: tab.label, + excludeSemantics: true, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSlide( + // 활성 아이콘은 1px 떠오른다 (translateY(-1px)) + offset: Offset(0, active ? -1 / AppControlSize.tabIcon : 0), + duration: AppDurations.fast, + child: icon, + ), + const SizedBox(height: AppSpacing.xs), + AnimatedDefaultTextStyle( + duration: AppDurations.fast, + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: color, + letterSpacing: 0, + ), + child: Text(tab.label, maxLines: 1, softWrap: false), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/splash/presentation/pages/splash_page.dart b/flutter/lib/features/splash/presentation/pages/splash_page.dart new file mode 100644 index 0000000..56cb069 --- /dev/null +++ b/flutter/lib/features/splash/presentation/pages/splash_page.dart @@ -0,0 +1,296 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/core/theme/app_typography.dart'; +import 'package:tma_app/core/utils/keyframes.dart'; +import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; +import 'package:tma_app/shared/widgets/design_art_box.dart'; +import 'package:tma_app/shared/widgets/drop_shadow_image.dart'; + +/// 스플래시 캔버스 — 시안은 720×1280 캔버스를 화면 폭에 맞춰 줄여 가운데 둔다 +const Size _kCanvas = Size(720, 1280); + +/// 시간축(초) — 시안 index.html 의 #splash-overlay 애니메이션 값 +const double _kTimelineSec = 3.2; +const Duration _kDismissAfter = Duration(milliseconds: 2600); +const Duration _kMaxWaitForImages = Duration(milliseconds: 1800); + +const Cubic _kBlobCurve = Cubic(.34, 1.56, .64, 1); +const Cubic _kSubtitleCurve = Cubic(.2, .8, .3, 1); +const Cubic _kDogCurve = Cubic(.22, 1.2, .36, 1); +const Cubic _kPopCurve = Cubic(.18, 1.5, .5, 1); + +/// 글자 R·U·P 등장 시각과 떠다니기 시작 시각 +const List _kLetterPopStart = [.62, .80, .98]; +const List _kLetterFloatStart = [1.5, 1.7, 1.9]; +const double _kPopDuration = .66; +const double _kFloatPeriod = 2.6; +const double _kFloatDistance = 9; + +const List _kPrecache = [ + AppAssets.splashBlob, + AppAssets.splashObjects, + AppAssets.splashLetterR, + AppAssets.splashLetterU, + AppAssets.splashLetterP, + AppAssets.splashDog, +]; + +/// 스플래시 — 이미지가 준비되면 등장 애니메이션을 재생하고 2.6초 뒤 다음 화면으로 넘어간다. +/// 재생이 시작된 뒤 화면을 누르면 바로 넘어간다. +class SplashPage extends ConsumerStatefulWidget { + const SplashPage({super.key}); + + @override + ConsumerState createState() => _SplashPageState(); +} + +class _SplashPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late final AnimationController _timeline = AnimationController( + vsync: this, + duration: Duration(milliseconds: (_kTimelineSec * 1000).round()), + ); + Timer? _dismissTimer; + bool _prepared = false; + bool _started = false; + bool _leaving = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_prepared) return; + _prepared = true; + unawaited(_prepareAndStart()); + } + + /// 이미지를 디코딩해 둔 뒤 재생한다 (첫 프레임에 빈 이미지가 스치지 않게). + /// 너무 오래 걸리면 기다리지 않고 시작한다. + Future _prepareAndStart() async { + await Future.wait([ + for (final asset in _kPrecache) precacheImage(AssetImage(asset), context), + ]).timeout(_kMaxWaitForImages, onTimeout: () => const []); + if (!mounted) return; + _started = true; + unawaited(_timeline.forward()); + _dismissTimer = Timer(_kDismissAfter, _dismiss); + } + + void _dismiss() { + if (_leaving || !mounted) return; + _leaving = true; + _dismissTimer?.cancel(); + context.go(AppRoutes.forStage(ref.read(entryProvider).stage)); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _timeline.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark, + child: Scaffold( + backgroundColor: AppColors.splashYellow, + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (_started) _dismiss(); + }, + child: Semantics( + label: '어플 하나로 완성하는 우리 아이 관리, rup', + child: DesignCover( + designSize: _kCanvas, + fit: BoxFit.contain, + child: AnimatedBuilder( + animation: _timeline, + builder: (context, _) => + _SplashScene(elapsed: _timeline.value * _kTimelineSec), + ), + ), + ), + ), + ), + ); + } +} + +/// 시간 [elapsed](초) 시점의 스플래시 장면 +class _SplashScene extends StatelessWidget { + const _SplashScene({required this.elapsed}); + + final double elapsed; + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + children: [ + _blob(), + _objects(), + _subtitle(), + _letter(0, AppAssets.splashLetterR, const Rect.fromLTWH(147, 354, 167, 212)), + _letter(1, AppAssets.splashLetterU, const Rect.fromLTWH(306, 383, 140, 183)), + _letter(2, AppAssets.splashLetterP, const Rect.fromLTWH(438, 387, 135, 179)), + _dog(), + ], + ); + } + + // 흰 구름 — scale(.74) translateY(10) → 원래 크기, 60% 지점에서 완전히 보인다 + Widget _blob() { + final p = segmentProgress(elapsed, .05, .72); + final scale = keyframeValue(p, const [0, 1], const [.74, 1], curve: _kBlobCurve); + final dy = keyframeValue(p, const [0, 1], const [10, 0], curve: _kBlobCurve); + final opacity = keyframeValue(p, const [0, .6, 1], const [0, 1, 1], curve: _kBlobCurve); + return Positioned( + left: -45, + top: 50, + width: 810, + height: 737, + child: _fade( + opacity, + Transform( + alignment: const Alignment(0, -.16), // transform-origin: 50% 42% + transform: Matrix4.identity() + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(0, dy, 0, 1), + child: const DropShadowImage( + asset: AppAssets.splashBlob, + shadowColor: Color(0x1FB4821E), // rgba(180,130,30,.12) + offset: Offset(0, 10), + blur: 26, + ), + ), + ), + ); + } + + // 배경 소품 — 늦게 서서히 나타난다 + Widget _objects() { + final p = segmentProgress(elapsed, .9, 1); + return Positioned.fill( + child: _fade( + Curves.ease.transform(p), + Image.asset(AppAssets.splashObjects, fit: BoxFit.fill), + ), + ); + } + + Widget _subtitle() { + final p = _kSubtitleCurve.transform(segmentProgress(elapsed, .42, .7)); + return Positioned( + left: 0, + right: 0, + top: 278, + child: _fade( + p, + Transform.translate( + offset: Offset(0, 16 * (1 - p)), + child: const Text( + '어플 하나로 완성하는 우리 아이 관리', + textAlign: TextAlign.center, + maxLines: 1, + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 35, + letterSpacing: -.5, + color: AppColors.splashInk, + ), + ), + ), + ), + ); + } + + // 글자 — 튀어 오르며 등장(pop) 후 위아래로 떠다닌다(floaty) + Widget _letter(int index, String asset, Rect rect) { + const stops = [0.0, .55, .74, .88, 1.0]; + final p = segmentProgress(elapsed, _kLetterPopStart[index], _kPopDuration); + final scale = keyframeValue(p, stops, const [0, 1.22, .9, 1.05, 1], curve: _kPopCurve); + final dy = keyframeValue(p, stops, const [34, -10, 4, -1, 0], curve: _kPopCurve); + final deg = keyframeValue(p, stops, const [-12, 6, -3, 1, 0], curve: _kPopCurve); + final opacity = keyframeValue(p, const [0, .55, 1], const [0, 1, 1], curve: _kPopCurve); + + return Positioned.fromRect( + rect: rect, + child: _fade( + opacity, + Transform( + alignment: const Alignment(0, .8), // transform-origin: 50% 90% + transform: Matrix4.identity() + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(0, dy, 0, 1) + ..rotateZ(deg * math.pi / 180), + child: Transform.translate( + offset: Offset(0, _floatOffset(_kLetterFloatStart[index])), + child: DropShadowImage( + asset: asset, + shadowColor: const Color(0x2EB04E00), // rgba(176,78,0,.18) + offset: const Offset(0, 3), + blur: 2, + ), + ), + ), + ), + ); + } + + /// floaty: 2.6초 주기로 0 → -9 → 0 (구간마다 ease-in-out) + double _floatOffset(double start) { + if (elapsed < start) return 0; + final t = ((elapsed - start) % _kFloatPeriod) / _kFloatPeriod; + return keyframeValue( + t, + const [0, .5, 1], + const [0, -_kFloatDistance, 0], + curve: Curves.easeInOut, + ); + } + + // 강아지 — 아래에서 올라오며 살짝 커진다 + Widget _dog() { + final p = segmentProgress(elapsed, .35, .9); + final dy = keyframeValue(p, const [0, 1], const [150, 0], curve: _kDogCurve); + final scale = keyframeValue(p, const [0, 1], const [.96, 1], curve: _kDogCurve); + final opacity = keyframeValue(p, const [0, .7, 1], const [0, 1, 1], curve: _kDogCurve); + return Positioned( + left: 70, + top: 736, + width: 600, + height: 506, + child: _fade( + opacity, + Transform( + alignment: Alignment.bottomCenter, + transform: Matrix4.identity() + ..translateByDouble(0, dy, 0, 1) + ..scaleByDouble(scale, scale, 1, 1), + child: const DropShadowImage( + asset: AppAssets.splashDog, + shadowColor: Color(0x29966414), // rgba(150,100,20,.16) + offset: Offset(0, 12), + blur: 18, + ), + ), + ), + ); + } + + Widget _fade(double opacity, Widget child) { + final o = opacity.clamp(0.0, 1.0); + if (o <= 0) return const SizedBox.shrink(); + return Opacity(opacity: o, child: child); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 0d77b72..2fa5a00 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -1,67 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/app.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; Future main() async { - // dotenv 로드 — .env 파일이 없으면 기본값으로 동작 WidgetsFlutterBinding.ensureInitialized(); + + // dotenv 로드 — .env 파일이 없으면 기본값으로 동작 try { await dotenv.load(fileName: '.env'); } catch (_) { // .env 파일이 없는 경우 무시 (기본값 사용) } + // 시안은 세로 화면 전용이다 + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + + // 진입 화면 결정에 필요한 저장값을 첫 프레임 전에 읽어 둔다 + final prefs = await SharedPreferences.getInstance(); + runApp( - // Riverpod Provider Scope 적용 - const ProviderScope( - child: MyApp(), + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const RupApp(), ), ); } - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'TMA App', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const HomeView(), - ); - } -} - -class HomeView extends StatelessWidget { - const HomeView({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('TMA Global Architecture'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Riverpod & Dio Interceptor Setup Complete.'), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - // 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI) - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')), - ); - }, - child: const Text('Show Global Alert Test'), - ), - ], - ), - ), - ); - } -} diff --git a/flutter/test/entry_redirect_test.dart b/flutter/test/entry_redirect_test.dart new file mode 100644 index 0000000..bdf6046 --- /dev/null +++ b/flutter/test/entry_redirect_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/features/entry/domain/entry_state.dart'; + +EntryState _state({bool intro = false, bool signedIn = false, bool onboarding = false}) { + return EntryState(introSeen: intro, signedIn: signedIn, onboardingSeen: onboarding); +} + +void main() { + group('EntryState.stage', () { + test('처음 설치하면 인트로 단계다', () { + expect(_state().stage, EntryStage.intro); + }); + + test('인트로를 봤지만 로그인 전이면 로그인 단계다', () { + expect(_state(intro: true).stage, EntryStage.login); + }); + + test('로그인했지만 온보딩을 안 봤으면 온보딩 단계다', () { + expect(_state(intro: true, signedIn: true).stage, EntryStage.onboarding); + }); + + test('로그인·온보딩을 마치면 앱 단계다', () { + expect(_state(intro: true, signedIn: true, onboarding: true).stage, EntryStage.app); + }); + + test('로그아웃하면 온보딩을 봤더라도 로그인 단계로 돌아간다', () { + expect(_state(intro: true, onboarding: true).stage, EntryStage.login); + }); + }); + + group('AppRoutes.redirectFor', () { + test('스플래시는 어느 단계에서도 그대로 둔다', () { + for (final stage in EntryStage.values) { + expect(AppRoutes.redirectFor(stage, AppRoutes.splash), isNull); + } + }); + + test('로그인 전에는 인트로와 로그인 사이만 오갈 수 있다', () { + expect(AppRoutes.redirectFor(EntryStage.login, AppRoutes.intro), isNull); + expect(AppRoutes.redirectFor(EntryStage.intro, AppRoutes.login), isNull); + expect(AppRoutes.redirectFor(EntryStage.login, AppRoutes.home), AppRoutes.login); + expect(AppRoutes.redirectFor(EntryStage.intro, AppRoutes.onboarding), AppRoutes.intro); + }); + + test('온보딩 단계에서는 온보딩 외 경로를 온보딩으로 보낸다', () { + expect(AppRoutes.redirectFor(EntryStage.onboarding, AppRoutes.login), AppRoutes.onboarding); + expect(AppRoutes.redirectFor(EntryStage.onboarding, AppRoutes.myPage), AppRoutes.onboarding); + expect(AppRoutes.redirectFor(EntryStage.onboarding, AppRoutes.onboarding), isNull); + }); + + test('앱 단계에서 진입 화면으로 가려 하면 홈으로 보낸다', () { + for (final route in [AppRoutes.intro, AppRoutes.login, AppRoutes.onboarding]) { + expect(AppRoutes.redirectFor(EntryStage.app, route), AppRoutes.home); + } + expect(AppRoutes.redirectFor(EntryStage.app, AppRoutes.records), isNull); + }); + }); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index bc87675..49814bc 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -1,15 +1,116 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tma_app/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/app.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; +import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; +import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:tma_app/features/shell/presentation/widgets/rup_tab_bar.dart'; +import 'package:tma_app/features/splash/presentation/pages/splash_page.dart'; + +/// 스플래시 자동 전환(2.6초) + 페이드(0.48초)를 넉넉히 넘기는 시간 +const Duration _kPastSplash = Duration(seconds: 4); + +/// 페이지 페이드 전환이 끝날 때까지 +const Duration _kPastFade = Duration(milliseconds: 600); + +Future _pumpApp(WidgetTester tester, Map saved) async { + // 시안 기기 크기(402×874)로 맞춘다 + tester.view.physicalSize = const Size(402, 874); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + SharedPreferences.setMockInitialValues(saved); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const RupApp(), + ), + ); +} + +/// 화면 전환 완료까지 — 새 화면은 첫 프레임에 offstage 로 그려지므로 한 프레임 더 진행한다 +Future _settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(_kPastFade); + await tester.pump(); +} + +/// 이미지 디코딩은 실제 비동기라 runAsync 로 기다린 뒤 가상 시간을 흘린다 +Future _passSplash(WidgetTester tester) async { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); + await tester.pump(_kPastSplash); + await _settle(tester); +} void main() { - testWidgets('앱이 정상적으로 렌더링된다', (WidgetTester tester) async { - await tester.pumpWidget( - const ProviderScope(child: MyApp()), - ); + testWidgets('앱을 처음 실행하면 스플래시로 시작한다', (tester) async { + await _pumpApp(tester, {}); + expect(find.byType(SplashPage), findsOneWidget); + await _passSplash(tester); + }); + testWidgets('첫 설치: 스플래시 → 인트로 → 로그인 → 온보딩 → 홈 순서로 진행한다', (tester) async { + await _pumpApp(tester, {}); + await _passSplash(tester); + expect(find.byType(IntroPage), findsOneWidget); + + await tester.tap(find.text('시작하기')); + await _settle(tester); + expect(find.byType(LoginPage), findsOneWidget); + + await tester.tap(find.text('카카오로 3초만에 시작하기')); + await _settle(tester); + expect(find.byType(OnboardingPage), findsOneWidget); + + // 네 장을 넘긴다 — 마지막 장은 '시작하기' 버튼으로 완료 + for (var i = 0; i < 3; i++) { + await tester.tap(find.byType(OnboardingPage)); + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.tap(find.text('시작하기')); + await _settle(tester); + + expect(find.byType(RupTabBar), findsOneWidget); + expect(find.text('마이페이지'), findsOneWidget); + }); + + testWidgets('로그인 화면의 뒤로가기는 인트로로 돌아간다', (tester) async { + await _pumpApp(tester, {'entry.introSeen': true}); + await _passSplash(tester); + expect(find.byType(LoginPage), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('뒤로')); + await _settle(tester); + expect(find.byType(IntroPage), findsOneWidget); + }); + + testWidgets('로그인·온보딩을 마친 사용자는 스플래시 다음 바로 홈으로 간다', (tester) async { + await _pumpApp(tester, { + 'entry.introSeen': true, + 'entry.onboardingSeen': true, + 'auth.demoSession': 'kakao', + }); + await _passSplash(tester); + expect(find.byType(RupTabBar), findsOneWidget); + expect(find.byType(IntroPage), findsNothing); + }); + + testWidgets('하단 탭을 누르면 해당 탭 화면으로 바뀐다', (tester) async { + await _pumpApp(tester, { + 'entry.introSeen': true, + 'entry.onboardingSeen': true, + 'auth.demoSession': 'kakao', + }); + await _passSplash(tester); + + await tester.tap(find.bySemanticsLabel('펫 명함')); + await _settle(tester); + // 탭 라벨 + 화면 제목 + expect(find.text('펫 명함'), findsNWidgets(2)); expect(find.byType(MaterialApp), findsOneWidget); - expect(find.byType(Scaffold), findsOneWidget); }); }