feat(flutter): 진입 흐름(스플래시→인트로→로그인→온보딩)과 하단 탭 셸 구현

시안의 첫 진입 흐름을 순서대로 옮겼다.
- 스플래시: 이미지 디코딩 후 등장 애니메이션 재생, 2.6초 뒤 진입 단계에 맞는 화면으로 이동
- 인트로·온보딩은 설치 후 1회만 노출, 로그아웃 시 로그인으로 복귀 (EntryState → 라우터 redirect)
- 온보딩 일러스트는 블러·마스크 때문에 레이어별 PNG 로 래스터화하고 움직임은 Flutter 로 재현
  (하단 안내문·시작하기 버튼은 비율이 다른 기기에서 잘리지 않도록 위젯으로 분리)
- 로그인은 DemoAuthRepository 로 임시 처리 — 소셜 SDK 앱 키와 백엔드 인증 API 연동 시 교체
- 하단 탭 4개 셸(StatefulShellRoute), 탭 화면은 흐름 순서대로 교체할 자리만 둔다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 18:54:14 +09:00
parent 8e2fbe79d8
commit 2371dff023
21 changed files with 1726 additions and 57 deletions
+19
View File
@@ -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),
);
}
}
+106
View File
@@ -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<void> _fadePage(GoRouterState state, Widget child) {
return CustomTransitionPage<void>(
key: state.pageKey,
child: child,
transitionDuration: AppDurations.fade,
reverseTransitionDuration: AppDurations.fade,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
}
final appRouterProvider = Provider<GoRouter>((ref) {
// 진입 상태가 바뀌면(로그인·온보딩 완료·로그아웃) redirect 를 다시 평가한다
final refresh = ValueNotifier<int>(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;
});
+43
View File
@@ -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<String> _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;
}
}
}
@@ -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<void> signIn(SocialProvider provider);
Future<void> 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<void> signIn(SocialProvider provider) async {
await _prefs.setString(_kDemoSessionKey, provider.name);
}
@override
Future<void> signOut() async {
await _prefs.remove(_kDemoSessionKey);
}
}
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return DemoAuthRepository(ref.watch(sharedPreferencesProvider));
});
@@ -0,0 +1,2 @@
/// 지원하는 소셜 로그인 제공자
enum SocialProvider { naver, kakao, google, apple }
@@ -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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
/// 진행 중인 로그인 — 중복 탭을 막는다
SocialProvider? _pending;
Future<void> _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<SystemUiOverlayStyle>(
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),
),
],
);
}
}
@@ -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 = '''
<svg viewBox="0 0 20 20"><path fill="#4285F4" d="M18 10.2c0-.6-.05-1.05-.16-1.52H10v2.87h4.55c-.09.76-.59 1.9-1.68 2.66l-.02.1 2.44 1.87.17.02C16.99 14.8 18 12.75 18 10.2Z"/><path fill="#34A853" d="M10 18c2.22 0 4.08-.72 5.46-1.96l-2.6-2c-.7.48-1.63.81-2.86.81a4.9 4.9 0 0 1-4.64-3.34l-.1.01-2.5 1.92-.03.09A9 9 0 0 0 10 18Z"/><path fill="#FBBC05" d="M5.36 11.51A5.4 5.4 0 0 1 5.06 10c0-.53.1-1.04.29-1.51l-.01-.1L2.8 6.44l-.08.04A8.9 8.9 0 0 0 1.8 10c0 1.28.32 2.48.92 3.52l2.64-2.01Z"/><path fill="#EA4335" d="M10 5.15c1.55 0 2.6.66 3.19 1.21l2.33-2.24C14.07 2.79 12.22 2 10 2a9 9 0 0 0-8.2 5.1l2.63 2.02A4.92 4.92 0 0 1 10 5.15Z"/></svg>
''';
/// 로고 마크를 버튼 왼쪽 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),
),
],
),
),
);
}
}
@@ -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<void> markIntroSeen() => _prefs.setBool(_kIntroSeenKey, true);
Future<void> markOnboardingSeen() => _prefs.setBool(_kOnboardingSeenKey, true);
}
final entryRepositoryProvider = Provider<EntryRepository>((ref) {
return EntryRepository(ref.watch(sharedPreferencesProvider));
});
@@ -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,
);
}
}
@@ -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<EntryState> {
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<void> completeIntro() async {
await _entry.markIntroSeen();
if (!ref.mounted) return;
state = state.copyWith(introSeen: true);
}
Future<void> signIn(SocialProvider provider) async {
await _auth.signIn(provider);
if (!ref.mounted) return;
state = state.copyWith(signedIn: true);
}
Future<void> completeOnboarding() async {
await _entry.markOnboardingSeen();
if (!ref.mounted) return;
state = state.copyWith(onboardingSeen: true);
}
/// 로그아웃하면 로그인 화면으로 돌아간다 (인트로·온보딩은 다시 보이지 않는다)
Future<void> signOut() async {
await _auth.signOut();
if (!ref.mounted) return;
state = state.copyWith(signedIn: false);
}
}
final entryProvider = NotifierProvider<EntryNotifier, EntryState>(
EntryNotifier.new,
);
@@ -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<SystemUiOverlayStyle>(
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),
),
],
);
}
}
@@ -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<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends ConsumerState<OnboardingPage> {
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<SystemUiOverlayStyle>(
// 마지막 장만 상단이 주황이다
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,
),
),
);
}
}
@@ -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<double> _kTwinkleStops = [0, .14, .26, .48, .70, 1];
const List<double> _kTwinkleOpacity = [0, 1, .95, .2, 1, .35];
const List<double> _kTwinkleScale = [.15, 1.2, 1, .8, 1.08, .9];
const List<double> _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<OnboardingScene> createState() => _OnboardingSceneState();
}
class _OnboardingSceneState extends State<OnboardingScene>
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<double> 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),
),
);
}
}
@@ -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;
}
@@ -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,
),
);
}
}
@@ -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(),
],
),
),
);
}
}
@@ -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<AppTab> 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),
),
],
),
),
),
);
}
}
@@ -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<double> _kLetterPopStart = [.62, .80, .98];
const List<double> _kLetterFloatStart = [1.5, 1.7, 1.9];
const double _kPopDuration = .66;
const double _kFloatPeriod = 2.6;
const double _kFloatDistance = 9;
const List<String> _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<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends ConsumerState<SplashPage>
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<void> _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<SystemUiOverlayStyle>(
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);
}
}
+15 -51
View File
@@ -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<void> 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'),
),
],
),
),
);
}
}