From 3e6206630fa22d06ff7791fdf02ba79b9572d4d2 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 21:51:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=84=EC=97=90=EB=8A=94=20=EC=95=B1=EC=9D=84=20=EC=97=B4=20?= =?UTF-8?q?=EB=95=8C=EB=A7=88=EB=8B=A4=20=EC=8B=9C=EC=9E=91=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=B3=B4=EC=97=AC=20?= =?UTF-8?q?=EC=A4=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "시작하기"를 한 번 누르면 기기에 저장해 이후 실행에서 인트로를 건너뛰었다. 그래서 시작하기를 누른 뒤 로그인하지 않고 앱을 다시 열면 스플래시 다음 바로 로그인 화면이 나왔다. 시작하기 여부는 이번 실행 동안만 기억한다. - 로그아웃은 시안대로 곧바로 로그인 화면으로 간다 - 예전 버전이 저장한 entry.introSeen 값은 더 이상 읽지 않는다 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/entry/data/entry_repository.dart | 8 ++--- .../features/entry/domain/entry_state.dart | 14 ++++---- .../providers/entry_provider.dart | 14 ++++---- .../intro/presentation/pages/intro_page.dart | 5 ++- flutter/test/entry_redirect_test.dart | 6 ++-- flutter/test/support/app_harness.dart | 1 - flutter/test/widget_test.dart | 33 +++++++++++++++++-- 7 files changed, 51 insertions(+), 30 deletions(-) diff --git a/flutter/lib/features/entry/data/entry_repository.dart b/flutter/lib/features/entry/data/entry_repository.dart index adda5c4..b9afe98 100644 --- a/flutter/lib/features/entry/data/entry_repository.dart +++ b/flutter/lib/features/entry/data/entry_repository.dart @@ -2,21 +2,17 @@ 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회만 보여 주는 화면(인트로·온보딩)의 확인 여부를 기기에 저장한다. +/// 설치 후 1회만 보여 주는 화면(온보딩)의 확인 여부를 기기에 저장한다. +/// (인트로는 로그인 전이면 실행할 때마다 보여 주므로 저장하지 않는다) class EntryRepository { EntryRepository(this._prefs); final SharedPreferences _prefs; - bool get introSeen => _prefs.getBool(_kIntroSeenKey) ?? false; - bool get onboardingSeen => _prefs.getBool(_kOnboardingSeenKey) ?? false; - Future markIntroSeen() => _prefs.setBool(_kIntroSeenKey, true); - Future markOnboardingSeen() => _prefs.setBool(_kOnboardingSeenKey, true); } diff --git a/flutter/lib/features/entry/domain/entry_state.dart b/flutter/lib/features/entry/domain/entry_state.dart index 8a38456..7f40075 100644 --- a/flutter/lib/features/entry/domain/entry_state.dart +++ b/flutter/lib/features/entry/domain/entry_state.dart @@ -1,27 +1,29 @@ -/// 진입 단계 — 설치 후 첫 진입: 스플래시 → 인트로 → 로그인 → 온보딩 → 앱 +/// 진입 단계 — 로그인 전: 스플래시 → 인트로 → 로그인 → (첫 로그인이면) 온보딩 → 앱 enum EntryStage { intro, login, onboarding, app } /// 진입 흐름 상태. 세 플래그로 지금 보여 줄 단계가 정해진다. class EntryState { const EntryState({ - required this.introSeen, + required this.introPassed, required this.signedIn, required this.onboardingSeen, }); - final bool introSeen; + /// 이번 실행에서 인트로의 "시작하기"를 눌렀는지 — 저장하지 않는다. + /// 로그인하지 않은 채 앱을 다시 열면 인트로부터 다시 보여 준다. + final bool introPassed; final bool signedIn; final bool onboardingSeen; EntryStage get stage { - if (!signedIn) return introSeen ? EntryStage.login : EntryStage.intro; + if (!signedIn) return introPassed ? EntryStage.login : EntryStage.intro; if (!onboardingSeen) return EntryStage.onboarding; return EntryStage.app; } - EntryState copyWith({bool? introSeen, bool? signedIn, bool? onboardingSeen}) { + EntryState copyWith({bool? introPassed, bool? signedIn, bool? onboardingSeen}) { return EntryState( - introSeen: introSeen ?? this.introSeen, + introPassed: introPassed ?? this.introPassed, signedIn: signedIn ?? this.signedIn, onboardingSeen: onboardingSeen ?? this.onboardingSeen, ); diff --git a/flutter/lib/features/entry/presentation/providers/entry_provider.dart b/flutter/lib/features/entry/presentation/providers/entry_provider.dart index 3df4f8f..4a8b561 100644 --- a/flutter/lib/features/entry/presentation/providers/entry_provider.dart +++ b/flutter/lib/features/entry/presentation/providers/entry_provider.dart @@ -12,17 +12,14 @@ class EntryNotifier extends Notifier { @override EntryState build() { return EntryState( - introSeen: ref.watch(entryRepositoryProvider).introSeen, + introPassed: false, signedIn: ref.watch(authRepositoryProvider).hasSession, onboardingSeen: ref.watch(entryRepositoryProvider).onboardingSeen, ); } - Future completeIntro() async { - await _entry.markIntroSeen(); - if (!ref.mounted) return; - state = state.copyWith(introSeen: true); - } + /// 인트로의 "시작하기" — 이번 실행 동안만 기억한다 + void completeIntro() => state = state.copyWith(introPassed: true); Future signIn(SocialProvider provider) async { await _auth.signIn(provider); @@ -36,11 +33,12 @@ class EntryNotifier extends Notifier { state = state.copyWith(onboardingSeen: true); } - /// 로그아웃하면 로그인 화면으로 돌아간다 (인트로·온보딩은 다시 보이지 않는다) + /// 로그아웃하면 곧바로 로그인 화면으로 돌아간다 (시안 — 인트로를 거치지 않는다). + /// 이후 앱을 새로 열면 로그인 전이므로 인트로부터 보인다. Future signOut() async { await _auth.signOut(); if (!ref.mounted) return; - state = state.copyWith(signedIn: false); + state = state.copyWith(signedIn: false, introPassed: true); } } diff --git a/flutter/lib/features/intro/presentation/pages/intro_page.dart b/flutter/lib/features/intro/presentation/pages/intro_page.dart index 4970b6b..e1b8908 100644 --- a/flutter/lib/features/intro/presentation/pages/intro_page.dart +++ b/flutter/lib/features/intro/presentation/pages/intro_page.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -23,12 +22,12 @@ 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()); + ref.read(entryProvider.notifier).completeIntro(); context.go(AppRoutes.login); } diff --git a/flutter/test/entry_redirect_test.dart b/flutter/test/entry_redirect_test.dart index bdf6046..19775f3 100644 --- a/flutter/test/entry_redirect_test.dart +++ b/flutter/test/entry_redirect_test.dart @@ -3,16 +3,16 @@ 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); + return EntryState(introPassed: intro, signedIn: signedIn, onboardingSeen: onboarding); } void main() { group('EntryState.stage', () { - test('처음 설치하면 인트로 단계다', () { + test('로그인 전에 앱을 열면 인트로 단계다', () { expect(_state().stage, EntryStage.intro); }); - test('인트로를 봤지만 로그인 전이면 로그인 단계다', () { + test('인트로에서 시작하기를 눌렀고 로그인 전이면 로그인 단계다', () { expect(_state(intro: true).stage, EntryStage.login); }); diff --git a/flutter/test/support/app_harness.dart b/flutter/test/support/app_harness.dart index 97306cb..9c45aec 100644 --- a/flutter/test/support/app_harness.dart +++ b/flutter/test/support/app_harness.dart @@ -8,7 +8,6 @@ import 'package:tma_app/features/pet/domain/pet.dart'; /// 로그인·온보딩까지 마친 사용자 (카카오 데모 로그인) const Map kSignedInPrefs = { - 'entry.introSeen': true, 'entry.onboardingSeen': true, 'auth.demoSession': 'kakao', }; diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index f1ce398..a346025 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -79,8 +79,10 @@ void main() { }); testWidgets('로그인 화면의 뒤로가기는 인트로로 돌아간다', (tester) async { - await _pumpApp(tester, {'entry.introSeen': true}); + await _pumpApp(tester, {}); await _passSplash(tester); + await tester.tap(find.text('시작하기')); + await _settle(tester); expect(find.byType(LoginPage), findsOneWidget); await tester.tap(find.bySemanticsLabel('뒤로')); @@ -88,9 +90,35 @@ void main() { expect(find.byType(IntroPage), findsOneWidget); }); + testWidgets('시작하기를 눌렀어도 로그인 전에 앱을 다시 열면 인트로부터 보여 준다', (tester) async { + await _pumpApp(tester, {}); + await _passSplash(tester); + await tester.tap(find.text('시작하기')); + await _settle(tester); + expect(find.byType(LoginPage), findsOneWidget); + + // 앱을 닫았다가 다시 연다 — 기기 저장소는 그대로, 메모리 상태만 새로 만든다 + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const RupApp(), + ), + ); + expect(find.byType(SplashPage), findsOneWidget); + await _passSplash(tester); + expect(find.byType(IntroPage), findsOneWidget); + }); + + testWidgets('예전 버전이 저장한 인트로 확인 값이 있어도 로그인 전이면 인트로를 보여 준다', (tester) async { + await _pumpApp(tester, {'entry.introSeen': true}); + await _passSplash(tester); + expect(find.byType(IntroPage), findsOneWidget); + }); + testWidgets('로그인·온보딩을 마친 사용자는 스플래시 다음 바로 홈으로 간다', (tester) async { await _pumpApp(tester, { - 'entry.introSeen': true, 'entry.onboardingSeen': true, 'auth.demoSession': 'kakao', }); @@ -101,7 +129,6 @@ void main() { testWidgets('하단 탭을 누르면 해당 탭 화면으로 바뀐다', (tester) async { await _pumpApp(tester, { - 'entry.introSeen': true, 'entry.onboardingSeen': true, 'auth.demoSession': 'kakao', });