generated from ttipo/project-base
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user