diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 987f8d7..c1b239b 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -15,6 +15,7 @@ import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dar import 'package:tma_app/features/diary/presentation/pages/diary_page.dart'; import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart'; import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart'; +import 'package:tma_app/features/entry/domain/entry_state.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; import 'package:tma_app/features/home/presentation/pages/home_page.dart'; import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; @@ -63,14 +64,26 @@ final appRouterProvider = Provider((ref) { // 탭바 위에 덮는 화면(등록 등)은 최상위 내비게이터에 올린다 final rootKey = GlobalKey(); + // 로그인 전에 받은 앱 링크 — 진입(인트로·로그인·온보딩)을 마치고 홈으로 갈 때 대신 연다. + // 길 찾기 중에 바꾸므로 프로바이더 상태가 아니라 이 라우터만 쓰는 값으로 둔다 + String? pendingLink; + final router = GoRouter( navigatorKey: rootKey, initialLocation: AppRoutes.splash, refreshListenable: refresh, - redirect: (context, state) => AppRoutes.redirectFor( - ref.read(entryProvider).stage, - state.matchedLocation, - ), + redirect: (context, state) { + final stage = ref.read(entryProvider).stage; + final location = state.matchedLocation; + final target = AppRoutes.redirectFor(stage, location); + if (target != null && stage != EntryStage.app && isAppLinkPath(location)) pendingLink = location; + if (stage == EntryStage.app && pendingLink != null && (target ?? location) == AppRoutes.home) { + final link = pendingLink; + pendingLink = null; + return link; + } + return target; + }, routes: [ GoRoute( path: AppRoutes.splash, diff --git a/flutter/lib/features/links/presentation/pages/link_landing_page.dart b/flutter/lib/features/links/presentation/pages/link_landing_page.dart index a621696..2f73c2c 100644 --- a/flutter/lib/features/links/presentation/pages/link_landing_page.dart +++ b/flutter/lib/features/links/presentation/pages/link_landing_page.dart @@ -153,6 +153,9 @@ class _LinkLandingPageState extends ConsumerState { } } +/// 앱 링크의 경로인지 — 로그인 전에 받은 링크를 기억할 때 (라우터가 쓴다) +bool isAppLinkPath(String path) => AppLinkKind.values.any((k) => path.startsWith(appLinkPattern(k).replaceAll(':code', ''))); + /// 링크의 경로 → 무엇을 열지 (라우터가 쓴다) String appLinkPattern(AppLinkKind kind) => switch (kind) { AppLinkKind.petCard => '/$kCardLinkSegment/:code', diff --git a/flutter/test/app_link_test.dart b/flutter/test/app_link_test.dart index 0482732..0c69c35 100644 --- a/flutter/test/app_link_test.dart +++ b/flutter/test/app_link_test.dart @@ -1,9 +1,17 @@ import 'dart:convert'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/app.dart'; +import 'package:tma_app/core/config/app_config.dart'; import 'package:tma_app/core/config/app_links.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/diary/domain/diary_invitation.dart'; +import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; import 'package:tma_app/features/pet_card/data/received_card_repository.dart'; import 'package:tma_app/features/pet_card/domain/card_style.dart'; import 'package:tma_app/features/pet_card/domain/card_view_mode.dart'; @@ -168,4 +176,41 @@ void main() { expect(find.byType(ShareCalendarPage), findsOneWidget); expect(find.text('2/4'), findsOneWidget); }); + + testWidgets('로그인 전에 받은 링크는 기억했다가 로그인을 마치면 연다', (tester) async { + tester.view.physicalSize = const Size(402, 874); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + // 온보딩은 봤지만 로그인하지 않은 사람 — 인트로부터 시작한다 + SharedPreferences.setMockInitialValues({'entry.onboardingSeen': true}); + final prefs = await SharedPreferences.getInstance(); + final cards = _CardRepo(); + await tester.pumpWidget(ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + dataModeProvider.overrideWithValue(DataMode.local), + receivedCardRepositoryProvider.overrideWithValue(cards), + ], + child: const RupApp(), + )); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); + await tester.pump(const Duration(seconds: 4)); + await settle(tester); + expect(find.byType(IntroPage), findsOneWidget); + + // 기기 카메라로 찍은 명함 링크 — 로그인 전이라 인트로에 머문다 + await _openLink(tester, GoRouter.of(tester.element(find.byType(IntroPage))), petCardDeepLink('0428-117')); + expect(find.byType(IntroPage), findsOneWidget); + expect(cards.previewed, isEmpty); + + await tester.tap(find.text('시작하기')); + await settle(tester); + expect(find.byType(LoginPage), findsOneWidget); + await tester.tap(find.text('카카오로 3초만에 시작하기')); + await settle(tester); + await settle(tester); + + expect(cards.previewed, ['0428-117']); + expect(find.byType(ReceiveCardPage), findsOneWidget); + }); }