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
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/entry/domain/entry_state.dart';
EntryState _state({bool intro = false, bool signedIn = false, bool onboarding = false}) {
return EntryState(introSeen: intro, signedIn: signedIn, onboardingSeen: onboarding);
}
void main() {
group('EntryState.stage', () {
test('처음 설치하면 인트로 단계다', () {
expect(_state().stage, EntryStage.intro);
});
test('인트로를 봤지만 로그인 전이면 로그인 단계다', () {
expect(_state(intro: true).stage, EntryStage.login);
});
test('로그인했지만 온보딩을 안 봤으면 온보딩 단계다', () {
expect(_state(intro: true, signedIn: true).stage, EntryStage.onboarding);
});
test('로그인·온보딩을 마치면 앱 단계다', () {
expect(_state(intro: true, signedIn: true, onboarding: true).stage, EntryStage.app);
});
test('로그아웃하면 온보딩을 봤더라도 로그인 단계로 돌아간다', () {
expect(_state(intro: true, onboarding: true).stage, EntryStage.login);
});
});
group('AppRoutes.redirectFor', () {
test('스플래시는 어느 단계에서도 그대로 둔다', () {
for (final stage in EntryStage.values) {
expect(AppRoutes.redirectFor(stage, AppRoutes.splash), isNull);
}
});
test('로그인 전에는 인트로와 로그인 사이만 오갈 수 있다', () {
expect(AppRoutes.redirectFor(EntryStage.login, AppRoutes.intro), isNull);
expect(AppRoutes.redirectFor(EntryStage.intro, AppRoutes.login), isNull);
expect(AppRoutes.redirectFor(EntryStage.login, AppRoutes.home), AppRoutes.login);
expect(AppRoutes.redirectFor(EntryStage.intro, AppRoutes.onboarding), AppRoutes.intro);
});
test('온보딩 단계에서는 온보딩 외 경로를 온보딩으로 보낸다', () {
expect(AppRoutes.redirectFor(EntryStage.onboarding, AppRoutes.login), AppRoutes.onboarding);
expect(AppRoutes.redirectFor(EntryStage.onboarding, AppRoutes.myPage), AppRoutes.onboarding);
expect(AppRoutes.redirectFor(EntryStage.onboarding, AppRoutes.onboarding), isNull);
});
test('앱 단계에서 진입 화면으로 가려 하면 홈으로 보낸다', () {
for (final route in [AppRoutes.intro, AppRoutes.login, AppRoutes.onboarding]) {
expect(AppRoutes.redirectFor(EntryStage.app, route), AppRoutes.home);
}
expect(AppRoutes.redirectFor(EntryStage.app, AppRoutes.records), isNull);
});
});
}
+107 -6
View File
@@ -1,15 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/app.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:tma_app/features/shell/presentation/widgets/rup_tab_bar.dart';
import 'package:tma_app/features/splash/presentation/pages/splash_page.dart';
/// 스플래시 자동 전환(2.6초) + 페이드(0.48초)를 넉넉히 넘기는 시간
const Duration _kPastSplash = Duration(seconds: 4);
/// 페이지 페이드 전환이 끝날 때까지
const Duration _kPastFade = Duration(milliseconds: 600);
Future<void> _pumpApp(WidgetTester tester, Map<String, Object> saved) async {
// 시안 기기 크기(402×874)로 맞춘다
tester.view.physicalSize = const Size(402, 874);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
SharedPreferences.setMockInitialValues(saved);
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: const RupApp(),
),
);
}
/// 화면 전환 완료까지 — 새 화면은 첫 프레임에 offstage 로 그려지므로 한 프레임 더 진행한다
Future<void> _settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(_kPastFade);
await tester.pump();
}
/// 이미지 디코딩은 실제 비동기라 runAsync 로 기다린 뒤 가상 시간을 흘린다
Future<void> _passSplash(WidgetTester tester) async {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
await tester.pump(_kPastSplash);
await _settle(tester);
}
void main() {
testWidgets('앱이 정상적으로 렌더링된다', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(child: MyApp()),
);
testWidgets('앱을 처음 실행하면 스플래시로 시작한다', (tester) async {
await _pumpApp(tester, {});
expect(find.byType(SplashPage), findsOneWidget);
await _passSplash(tester);
});
testWidgets('첫 설치: 스플래시 → 인트로 → 로그인 → 온보딩 → 홈 순서로 진행한다', (tester) async {
await _pumpApp(tester, {});
await _passSplash(tester);
expect(find.byType(IntroPage), findsOneWidget);
await tester.tap(find.text('시작하기'));
await _settle(tester);
expect(find.byType(LoginPage), findsOneWidget);
await tester.tap(find.text('카카오로 3초만에 시작하기'));
await _settle(tester);
expect(find.byType(OnboardingPage), findsOneWidget);
// 네 장을 넘긴다 — 마지막 장은 '시작하기' 버튼으로 완료
for (var i = 0; i < 3; i++) {
await tester.tap(find.byType(OnboardingPage));
await tester.pump(const Duration(milliseconds: 100));
}
await tester.tap(find.text('시작하기'));
await _settle(tester);
expect(find.byType(RupTabBar), findsOneWidget);
expect(find.text('마이페이지'), findsOneWidget);
});
testWidgets('로그인 화면의 뒤로가기는 인트로로 돌아간다', (tester) async {
await _pumpApp(tester, {'entry.introSeen': true});
await _passSplash(tester);
expect(find.byType(LoginPage), findsOneWidget);
await tester.tap(find.bySemanticsLabel('뒤로'));
await _settle(tester);
expect(find.byType(IntroPage), findsOneWidget);
});
testWidgets('로그인·온보딩을 마친 사용자는 스플래시 다음 바로 홈으로 간다', (tester) async {
await _pumpApp(tester, {
'entry.introSeen': true,
'entry.onboardingSeen': true,
'auth.demoSession': 'kakao',
});
await _passSplash(tester);
expect(find.byType(RupTabBar), findsOneWidget);
expect(find.byType(IntroPage), findsNothing);
});
testWidgets('하단 탭을 누르면 해당 탭 화면으로 바뀐다', (tester) async {
await _pumpApp(tester, {
'entry.introSeen': true,
'entry.onboardingSeen': true,
'auth.demoSession': 'kakao',
});
await _passSplash(tester);
await tester.tap(find.bySemanticsLabel('펫 명함'));
await _settle(tester);
// 탭 라벨 + 화면 제목
expect(find.text('펫 명함'), findsNWidgets(2));
expect(find.byType(MaterialApp), findsOneWidget);
expect(find.byType(Scaffold), findsOneWidget);
});
}