From 3f84d7b74783047a38c4c204aa08df9c8794f9c8 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 22:50:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=95=B1=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=E2=80=94=20=EA=B8=B0=EA=B8=B0=20=EC=B9=B4=EB=A9=94=EB=9D=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B0=8D=EC=9D=80=20QR=20=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EA=B8=B0=C2=B7=EC=B0=B8=EC=97=AC=C2=B7?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR 을 앱 스캐너가 아니라 기기 카메라로 찍어도 앱이 열려 이어지게 한다. - rup://app/<경로> 링크 (Android intent-filter · iOS URL scheme) — Android 는 호스트를 버리고 경로만 넘기므로 경로로 구분한다 - 명함 → 저장 확인 화면, 다이어리 초대 → 참여 확인, 일정 초대 → 연동 확인 (끝나면 그 기능의 탭으로) - QR 에 담는 주소를 새 형식으로 바꾸고, 예전 형식(rup://pet-card/…)도 계속 읽는다 Co-Authored-By: Claude Opus 5.5 --- .../android/app/src/main/AndroidManifest.xml | 8 + flutter/ios/Runner/Info.plist | 15 +- flutter/lib/core/config/app_links.dart | 19 ++ flutter/lib/core/router/app_router.dart | 8 + .../diary/domain/diary_invitation.dart | 15 +- .../presentation/widgets/diary_join.dart | 19 +- .../presentation/pages/link_landing_page.dart | 161 +++++++++++++++++ .../pet_card/domain/received_card.dart | 16 +- .../presentation/pages/receive_card_page.dart | 5 +- .../schedule/domain/calendar_sharing.dart | 15 +- .../widgets/calendar_link_flow.dart | 29 ++- flutter/test/app_link_test.dart | 171 ++++++++++++++++++ 12 files changed, 446 insertions(+), 35 deletions(-) create mode 100644 flutter/lib/core/config/app_links.dart create mode 100644 flutter/lib/features/links/presentation/pages/link_landing_page.dart create mode 100644 flutter/test/app_link_test.dart diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 594262a..779e3af 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,14 @@ + + + + + + + + diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist index 151c9b7..9d62cc8 100644 --- a/flutter/ios/Runner/Info.plist +++ b/flutter/ios/Runner/Info.plist @@ -8,8 +8,21 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Tma App + CFBundleURLTypes + + + CFBundleURLName + app.rup.links + CFBundleURLSchemes + + rup + + + + FlutterDeepLinkingEnabled + NSCameraUsageDescription - 다른 반려인의 펫 명함 QR 코드를 읽기 위해 카메라를 사용합니다. + 펫 명함·공유 다이어리·일정 연동 QR 코드를 읽기 위해 카메라를 사용합니다. NSPhotoLibraryAddUsageDescription 펫 명함 이미지를 사진 보관함에 저장합니다. NSPhotoLibraryUsageDescription diff --git a/flutter/lib/core/config/app_links.dart b/flutter/lib/core/config/app_links.dart new file mode 100644 index 0000000..14cd042 --- /dev/null +++ b/flutter/lib/core/config/app_links.dart @@ -0,0 +1,19 @@ +// 앱 링크 — QR 에 담아 기기 카메라로 찍어도 앱이 열리게 한다 (Android intent-filter · iOS URL scheme 이 `rup` 을 받는다). +// +// 앱에는 링크의 **경로**만 들어온다 (Android 는 호스트를 버린다). 그래서 호스트는 `app` 하나로 두고 +// 무엇을 열지는 경로로 정한다: rup://app/pet-card/0428-117 → 라우터 경로 /pet-card/0428-117 + +/// 링크 앞부분 — 뒤에 라우터 경로를 붙인다 +const String kAppLinkBase = 'rup://app'; + +/// 라우터 경로 → 앱 링크 +String appLink(String path) => '$kAppLinkBase$path'; + +/// 링크에서 [segment] 다음 값을 꺼낸다 — `rup://app//값` 과 예전 형식 `rup:///값` 모두. +/// 링크가 아니면 받은 글자 그대로 (직접 입력한 코드) +String stripAppLink(String text, String segment) { + for (final prefix in ['$kAppLinkBase/$segment/', 'rup://$segment/']) { + if (text.startsWith(prefix)) return text.substring(prefix.length); + } + return text; +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 3817bc7..987f8d7 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -18,6 +18,7 @@ import 'package:tma_app/features/diary/presentation/widgets/diary_join.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'; +import 'package:tma_app/features/links/presentation/pages/link_landing_page.dart'; import 'package:tma_app/features/mypage/domain/policy_doc.dart'; import 'package:tma_app/features/mypage/presentation/pages/my_page.dart'; import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart'; @@ -133,6 +134,13 @@ final appRouterProvider = Provider((ref) { ), ], ), + // 앱 링크 — 기기 카메라로 QR 을 찍어 열렸을 때 (rup://app/pet-card/… 등, core/config/app_links.dart) + for (final kind in AppLinkKind.values) + GoRoute( + path: appLinkPattern(kind), + parentNavigatorKey: rootKey, + builder: (context, state) => LinkLandingPage(kind: kind, code: state.pathParameters['code']!), + ), GoRoute( path: AppRoutes.shareCalendar, parentNavigatorKey: rootKey, diff --git a/flutter/lib/features/diary/domain/diary_invitation.dart b/flutter/lib/features/diary/domain/diary_invitation.dart index fc97daa..84512b8 100644 --- a/flutter/lib/features/diary/domain/diary_invitation.dart +++ b/flutter/lib/features/diary/domain/diary_invitation.dart @@ -1,3 +1,5 @@ +import 'package:tma_app/core/config/app_links.dart'; + /// 공유 다이어리 초대 — 참여하기 전에 확인하는 다이어리 정보 class DiaryInvitation { const DiaryInvitation({ @@ -26,17 +28,18 @@ class DiaryInvitation { /// 초대 코드 앞머리 (서버가 `RUP-` + 6자로 만든다) const String kInviteCodePrefix = 'RUP-'; -/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽는다 -String diaryInviteLink(String code) => 'rup://diary-invite/$code'; +/// 다이어리 초대 링크의 경로 앞부분 — rup://app/diary-invite/RUP-8F3K2Q (라우터 경로 /diary-invite/:code) +const String kDiaryInviteLinkSegment = 'diary-invite'; + +/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽고, 기기 카메라로 찍으면 앱이 열려 바로 참여 확인으로 간다 +String diaryInviteLink(String code) => appLink('/$kDiaryInviteLinkSegment/$code'); final RegExp _kInviteBody = RegExp(r'^[A-Z0-9]{6}$'); -/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://diary-invite/RUP-8F3K2Q)· +/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://app/diary-invite/RUP-8F3K2Q, 예전 rup://diary-invite/…)· /// 코드(RUP-8F3K2Q, 소문자도)·앞머리 없이 쓴 6자(8F3K2Q) 모두 받는다. 초대 코드가 아니면 null String? parseInviteCode(String raw) { - var text = raw.trim(); - const link = 'rup://diary-invite/'; - if (text.startsWith(link)) text = text.substring(link.length); + var text = stripAppLink(raw.trim(), kDiaryInviteLinkSegment); text = text.split('?').first.trim().toUpperCase().replaceAll(' ', ''); if (text.startsWith(kInviteCodePrefix)) text = text.substring(kInviteCodePrefix.length); return _kInviteBody.hasMatch(text) ? '$kInviteCodePrefix$text' : null; diff --git a/flutter/lib/features/diary/presentation/widgets/diary_join.dart b/flutter/lib/features/diary/presentation/widgets/diary_join.dart index 69bacd5..5c078d5 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_join.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_join.dart @@ -96,7 +96,8 @@ Future startJoiningDiary(BuildContext context, WidgetRef ref) async { case null: return; case _Found(invitation: final found): - return _confirmJoin(context, ref, found); + await confirmDiaryJoin(context, ref, found); + return; case _ChoseScan(): final outcome = await context.push>(AppRoutes.diaryJoinScan); switch (outcome) { @@ -109,19 +110,20 @@ Future startJoiningDiary(BuildContext context, WidgetRef ref) async { } } } - if (context.mounted) await _confirmJoin(context, ref, invitation); + if (context.mounted) await confirmDiaryJoin(context, ref, invitation); } -/// 참여 전 확인 — 이미 함께 쓰거나 자리가 없으면 서버와 같은 안내로 멈춘다 -Future _confirmJoin(BuildContext context, WidgetRef ref, DiaryInvitation invitation) async { +/// 참여 전 확인 — 이미 함께 쓰거나 자리가 없으면 서버와 같은 안내로 멈춘다 (앱 링크로 열 때도 쓴다) +/// 참여했으면 true +Future confirmDiaryJoin(BuildContext context, WidgetRef ref, DiaryInvitation invitation) async { final toast = ref.read(toastProvider.notifier); if (invitation.isMember) { toast.show(ErrorLexicon.getMessage('DIARY_005'), icon: RupIconName.alert); - return; + return false; } if (invitation.isFull) { toast.show(ErrorLexicon.getMessage('DIARY_004'), icon: RupIconName.alert); - return; + return false; } final ok = await showRupConfirm( context, @@ -137,9 +139,10 @@ Future _confirmJoin(BuildContext context, WidgetRef ref, DiaryInvitation i iconColor: AppColors.accent, iconBackground: AppColors.accent100, ); - if (!ok) return; - if (!await succeeded(ref.read(diaryProvider.notifier).join(invitation.code))) return; + if (!ok) return false; + if (!await succeeded(ref.read(diaryProvider.notifier).join(invitation.code))) return false; toast.show('공유 다이어리에 참여했어요'); + return true; } /// 참여하기 시트 — QR 스캔 / 또는 / 초대 코드 입력 + 참여하기 diff --git a/flutter/lib/features/links/presentation/pages/link_landing_page.dart b/flutter/lib/features/links/presentation/pages/link_landing_page.dart new file mode 100644 index 0000000..a621696 --- /dev/null +++ b/flutter/lib/features/links/presentation/pages/link_landing_page.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/api/api_guard.dart'; +import 'package:tma_app/core/api/error_lexicon.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/features/diary/domain/diary.dart'; +import 'package:tma_app/features/diary/domain/diary_invitation.dart'; +import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart'; +import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart'; +import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart'; +import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart'; +import 'package:tma_app/features/schedule/domain/calendar_sharing.dart'; +import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart'; +import 'package:tma_app/features/schedule/presentation/widgets/calendar_link_flow.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; + +/// 앱 링크로 여는 것 — 기기 카메라로 QR 을 찍어 앱이 열렸을 때 +enum AppLinkKind { + /// 펫 명함 받기 — rup://app/pet-card/<명함 코드> + petCard, + + /// 공유 다이어리 참여 — rup://app/diary-invite/<초대 코드> + diaryInvite, + + /// 일정 연동 — rup://app/calendar-invite/<초대 코드> + calendarInvite, +} + +/// 앱 링크 도착 화면 — 링크의 코드를 서버에서 확인하고 받기·참여·연동 확인으로 이어 준다. +/// 끝나면(취소·실패 포함) 그 기능의 탭으로 보낸다. 실패 알림은 인터셉터가 띄운다. +class LinkLandingPage extends ConsumerStatefulWidget { + const LinkLandingPage({super.key, required this.kind, required this.code}); + + final AppLinkKind kind; + + /// 링크에 담긴 코드 (아직 확인 전) + final String code; + + @override + ConsumerState createState() => _LinkLandingPageState(); +} + +class _LinkLandingPageState extends ConsumerState { + @override + void initState() { + super.initState(); + // 첫 화면을 그린 뒤 시작한다 — 확인 창은 이 화면 위에 뜬다 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_open()); + }); + } + + ToastNotifier get _toast => ref.read(toastProvider.notifier); + + Future _open() async { + final router = GoRouter.of(context); + try { + switch (widget.kind) { + case AppLinkKind.petCard: + await _openCard(router); + case AppLinkKind.diaryInvite: + await _openDiary(router); + case AppLinkKind.calendarInvite: + await _openCalendar(router); + } + } catch (e) { + // 서버 실패는 인터셉터가 알렸다 — 그 기능의 탭으로 보낸다 + if (!isReportedApiFailure(e)) rethrow; + router.go(switch (widget.kind) { + AppLinkKind.petCard => AppRoutes.petCards, + AppLinkKind.diaryInvite || AppLinkKind.calendarInvite => AppRoutes.records, + }); + } + } + + /// 명함 — 미리 보고 저장 확인 화면으로 (펫 명함 탭 위에 연다) + Future _openCard(GoRouter router) async { + final cards = ref.read(receivedCardsProvider.notifier); + final code = parseCardCode(widget.code); + if (!cards.available || code == null) { + _toast.show( + cards.available ? ErrorLexicon.getMessage('CARD_002') : kReceiveCardLocalOnly, + icon: RupIconName.qrcode, + ); + router.go(AppRoutes.petCards); + return; + } + final card = await cards.preview(code); + router.go(AppRoutes.petCards); + unawaited(router.push(AppRoutes.petCardReceive, extra: card)); + } + + /// 다이어리 — 미리 보고 참여 확인, 끝나면 다이어리 책장으로 + Future _openDiary(GoRouter router) async { + final diaries = ref.read(diaryProvider.notifier); + ref.read(recordsTabProvider.notifier).select(RecordsTab.diary); + final code = parseInviteCode(widget.code); + // 책장이 가득 찼는지 보려면 다이어리를 먼저 읽어야 한다 + await ref.read(diaryProvider.future); + final stop = !diaries.canShare + ? kSharedDiaryLocalOnly + : code == null + ? ErrorLexicon.getMessage('DIARY_003') + : diaries.atMax + ? '다이어리는 최대 $kMaxDiaries개까지예요' + : null; + if (stop != null || code == null) { + _toast.show(stop!, icon: RupIconName.users); + router.go(AppRoutes.records); + return; + } + final invitation = await diaries.previewInvitation(code); + if (mounted) await confirmDiaryJoin(context, ref, invitation); + router.go(AppRoutes.records); + } + + /// 일정 — 미리 보고 연동 확인, 연동했으면 함께 관리 화면까지 + Future _openCalendar(GoRouter router) async { + ref.read(recordsTabProvider.notifier).select(RecordsTab.schedule); + final code = parseCalendarCode(widget.code); + if (!await canStartCalendarLink(ref)) { + router.go(AppRoutes.records); + return; + } + if (code == null) { + _toast.show(ErrorLexicon.getMessage('SCHED_001'), icon: RupIconName.users); + router.go(AppRoutes.records); + return; + } + final invitation = await ref.read(calendarSharingProvider.notifier).previewInvitation(code); + final linked = mounted && await confirmCalendarLink(context, ref, invitation); + router.go(AppRoutes.records); + if (linked) unawaited(router.push(AppRoutes.shareCalendar)); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: SizedBox.square( + dimension: 28, + child: CircularProgressIndicator(strokeWidth: 2.6, color: AppColors.primary), + ), + ), + ); + } +} + +/// 링크의 경로 → 무엇을 열지 (라우터가 쓴다) +String appLinkPattern(AppLinkKind kind) => switch (kind) { + AppLinkKind.petCard => '/$kCardLinkSegment/:code', + AppLinkKind.diaryInvite => '/$kDiaryInviteLinkSegment/:code', + AppLinkKind.calendarInvite => '/$kCalendarInviteLinkSegment/:code', + }; diff --git a/flutter/lib/features/pet_card/domain/received_card.dart b/flutter/lib/features/pet_card/domain/received_card.dart index ad99873..c5febc6 100644 --- a/flutter/lib/features/pet_card/domain/received_card.dart +++ b/flutter/lib/features/pet_card/domain/received_card.dart @@ -1,3 +1,4 @@ +import 'package:tma_app/core/config/app_links.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet_card/domain/card_style.dart'; import 'package:tma_app/features/pet_card/domain/card_view_mode.dart'; @@ -63,17 +64,18 @@ class ReceivedCard extends SharedCard { } } -/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽는다 (시스템 카메라로 찍으면 앱을 연다) -String petCardDeepLink(String code) => 'rup://pet-card/$code'; +/// 명함 링크의 경로 앞부분 — rup://app/pet-card/0428-117 (라우터 경로 /pet-card/:code) +const String kCardLinkSegment = 'pet-card'; + +/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽고, 기기 카메라로 찍으면 앱이 열려 바로 받기로 간다 +String petCardDeepLink(String code) => appLink('/$kCardLinkSegment/$code'); final RegExp _kCardCode = RegExp(r'^\d{4}-\d{3}$'); -/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://pet-card/0428-117)·코드(0428-117)· -/// 하이픈 없이 쓴 7자리(0428117) 모두 받는다. 명함 코드가 아니면 null +/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://app/pet-card/0428-117, 예전 rup://pet-card/…)· +/// 코드(0428-117)·하이픈 없이 쓴 7자리(0428117) 모두 받는다. 명함 코드가 아니면 null String? parseCardCode(String raw) { - var text = raw.trim(); - const prefix = 'rup://pet-card/'; - if (text.startsWith(prefix)) text = text.substring(prefix.length); + var text = stripAppLink(raw.trim(), kCardLinkSegment); text = text.split('?').first.trim(); if (RegExp(r'^\d{7}$').hasMatch(text)) text = '${text.substring(0, 4)}-${text.substring(4)}'; return _kCardCode.hasMatch(text) ? text : null; diff --git a/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart b/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart index cd90f44..888b7cb 100644 --- a/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart @@ -20,10 +20,13 @@ import 'package:tma_app/shared/widgets/rup_button.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; import 'package:tma_app/shared/widgets/section_title.dart'; +/// 기기 저장 모드 안내 — 받을 명함은 다른 회원의 것이라 서버가 있어야 한다 +const String kReceiveCardLocalOnly = '기기 저장 모드에서는 명함을 받을 수 없어요'; + /// 명함 받기 — 스캐너(또는 코드 입력)로 명함을 찾아 저장 확인 화면을 연다 Future startReceivingCard(BuildContext context, WidgetRef ref) async { if (!ref.read(receivedCardsProvider.notifier).available) { - ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode); + ref.read(toastProvider.notifier).show(kReceiveCardLocalOnly, icon: RupIconName.qrcode); return; } final outcome = await context.push>(AppRoutes.petCardScan); diff --git a/flutter/lib/features/schedule/domain/calendar_sharing.dart b/flutter/lib/features/schedule/domain/calendar_sharing.dart index 0c66ec9..0e7a134 100644 --- a/flutter/lib/features/schedule/domain/calendar_sharing.dart +++ b/flutter/lib/features/schedule/domain/calendar_sharing.dart @@ -1,3 +1,5 @@ +import 'package:tma_app/core/config/app_links.dart'; + /// 함께 관리 — 한 달력을 최대 4명이 함께 쓴다 (시안 MAX_LINKED) const int kMaxCalendarMembers = 4; @@ -79,17 +81,18 @@ class CalendarInvitation { /// 초대 코드 앞머리 (서버가 `RUP-` + 6자로 만든다 — 코드만 알면 달력에 들어오므로 짐작하기 어렵게) const String kCalendarCodePrefix = 'RUP-'; -/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽는다 -String calendarInviteLink(String code) => 'rup://calendar-invite/$code'; +/// 일정 연동 링크의 경로 앞부분 — rup://app/calendar-invite/RUP-8K2D7Q (라우터 경로 /calendar-invite/:code) +const String kCalendarInviteLinkSegment = 'calendar-invite'; + +/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽고, 기기 카메라로 찍으면 앱이 열려 바로 연동 확인으로 간다 +String calendarInviteLink(String code) => appLink('/$kCalendarInviteLinkSegment/$code'); final RegExp _kCodeBody = RegExp(r'^[A-Z0-9]{6}$'); -/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://calendar-invite/RUP-8K2D7Q)· +/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://app/calendar-invite/RUP-8K2D7Q, 예전 rup://calendar-invite/…)· /// 코드(RUP-8K2D7Q, 소문자도)·앞머리 없이 쓴 6자(8K2D7Q) 모두 받는다. 초대 코드가 아니면 null String? parseCalendarCode(String raw) { - var text = raw.trim(); - const link = 'rup://calendar-invite/'; - if (text.startsWith(link)) text = text.substring(link.length); + var text = stripAppLink(raw.trim(), kCalendarInviteLinkSegment); text = text.split('?').first.trim().toUpperCase().replaceAll(' ', ''); if (text.startsWith(kCalendarCodePrefix)) text = text.substring(kCalendarCodePrefix.length); return _kCodeBody.hasMatch(text) ? '$kCalendarCodePrefix$text' : null; diff --git a/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart b/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart index 61d83c0..2277a04 100644 --- a/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart +++ b/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart @@ -116,11 +116,27 @@ Future startCalendarLink(BuildContext context, WidgetRef ref) async { } } } - if (context.mounted) await _confirmLink(context, ref, invitation); + if (context.mounted) await confirmCalendarLink(context, ref, invitation); } -/// 연동 전 확인 — 내 코드·이미 연동·가득 참은 서버와 같은 안내로 멈춘다 -Future _confirmLink(BuildContext context, WidgetRef ref, CalendarInvitation invitation) async { +/// 받은 링크로 연동할 수 있는지 먼저 확인한다 — 기기 저장 모드·이미 다른 사람과 연동이면 안내하고 false +Future canStartCalendarLink(WidgetRef ref) async { + final toast = ref.read(toastProvider.notifier); + final sharing = await ref.read(calendarSharingProvider.future); + if (sharing == null) { + toast.show(kCalendarSharingLocalOnly, icon: RupIconName.users); + return false; + } + if (sharing.linked) { + toast.show(ErrorLexicon.getMessage('SCHED_004'), icon: RupIconName.users); + return false; + } + return true; +} + +/// 연동 전 확인 — 내 코드·이미 연동·가득 참은 서버와 같은 안내로 멈춘다 (앱 링크로 열 때도 쓴다) +/// 연동했으면 true +Future confirmCalendarLink(BuildContext context, WidgetRef ref, CalendarInvitation invitation) async { final toast = ref.read(toastProvider.notifier); final stop = invitation.isMine ? 'SCHED_003' @@ -131,7 +147,7 @@ Future _confirmLink(BuildContext context, WidgetRef ref, CalendarInvitatio : null; if (stop != null) { toast.show(ErrorLexicon.getMessage(stop), icon: RupIconName.users); - return; + return false; } final ok = await showRupConfirm( context, @@ -143,9 +159,10 @@ Future _confirmLink(BuildContext context, WidgetRef ref, CalendarInvitatio iconColor: AppColors.primary600, iconBackground: AppColors.primary50, ); - if (!ok) return; - if (!await succeeded(ref.read(calendarSharingProvider.notifier).join(invitation.code))) return; + if (!ok) return false; + if (!await succeeded(ref.read(calendarSharingProvider.notifier).join(invitation.code))) return false; toast.show('새로운 멤버와 연동했어요', icon: RupIconName.users); + return true; } /// 초대 코드로 연동 시트 — QR 스캔 / 코드 입력 + 안내 + 연동하기 diff --git a/flutter/test/app_link_test.dart b/flutter/test/app_link_test.dart new file mode 100644 index 0000000..0482732 --- /dev/null +++ b/flutter/test/app_link_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/config/app_links.dart'; +import 'package:tma_app/features/diary/domain/diary_invitation.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'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart'; +import 'package:tma_app/features/schedule/data/calendar_sharing_repository.dart'; +import 'package:tma_app/features/schedule/domain/calendar_sharing.dart'; +import 'package:tma_app/features/schedule/presentation/pages/share_calendar_page.dart'; +import 'package:tma_app/features/shell/presentation/pages/main_shell.dart'; + +import 'support/app_harness.dart'; + +/// 명함 미리보기만 하는 저장소 +class _CardRepo implements ReceivedCardRepository { + final previewed = []; + + @override + bool get available => true; + + @override + Future> fetch() async => const []; + + @override + Future preview(String code) async { + previewed.add(code); + return SharedCard( + code: code, + mode: CardViewMode.simple, + senderNickname: '초코아빠', + senderPhotoPath: null, + pet: samplePet(id: 'shared', name: '초코'), + style: CardStyle.initial(), + ); + } + + @override + Future receive(String code, {String? note}) => throw UnimplementedError(); + + @override + Future delete(String id) async {} +} + +/// 함께 관리 — 연동 전, 받은 코드는 콩이아빠의 것 +class _SharingRepo implements CalendarSharingRepository { + bool linked = false; + final joined = []; + + CalendarSharing get _sharing => CalendarSharing( + inviteCode: 'RUP-MINE00', + linked: linked, + members: [ + const CalendarPerson(id: 'me', isMe: true), + if (linked) const CalendarPerson(id: 'dad', nickname: '콩이아빠'), + ], + ); + + @override + bool get available => true; + + @override + Future fetch({bool quiet = false}) async { + return _sharing; + } + + @override + Future previewInvitation(String code) async => CalendarInvitation( + code: code, + ownerNickname: '콩이아빠', + memberCount: 1, + maxMembers: 4, + isMine: false, + isMember: false, + ); + + @override + Future join(String code) async { + joined.add(code); + linked = true; + return _sharing; + } + + @override + Future leave() async => linked = false; +} + +Future _launch(WidgetTester tester, {ReceivedCardRepository? cards, CalendarSharingRepository? sharing}) async { + await launchSignedIn( + tester, + extra: {'pets.v1': jsonEncode([samplePet().toJson()])}, + overrides: [ + if (cards != null) receivedCardRepositoryProvider.overrideWithValue(cards), + if (sharing != null) calendarSharingRepositoryProvider.overrideWithValue(sharing), + ], + ); + return GoRouter.of(tester.element(find.byType(MainShell))); +} + +/// 기기 카메라로 찍은 링크가 들어온 것처럼 — 앱에는 링크의 경로만 들어온다 +Future _openLink(WidgetTester tester, GoRouter router, String link) async { + router.go(Uri.parse(link).path); + await settle(tester); + await settle(tester); +} + +void main() { + group('앱 링크 주소', () { + test('QR 에 담는 주소는 rup://app/ 아래 경로다', () { + expect(petCardDeepLink('0428-117'), 'rup://app/pet-card/0428-117'); + expect(diaryInviteLink('RUP-8F3K2Q'), 'rup://app/diary-invite/RUP-8F3K2Q'); + expect(calendarInviteLink('RUP-8K2D7Q'), 'rup://app/calendar-invite/RUP-8K2D7Q'); + expect(Uri.parse(petCardDeepLink('0428-117')).path, '/pet-card/0428-117'); + }); + + test('새 주소와 예전 주소를 모두 코드로 읽는다', () { + expect(parseCardCode('rup://app/pet-card/0428-117'), '0428-117'); + expect(parseCardCode('rup://pet-card/0428-117'), '0428-117'); + expect(parseInviteCode('rup://app/diary-invite/RUP-8F3K2Q'), 'RUP-8F3K2Q'); + expect(parseCalendarCode('rup://app/calendar-invite/rup-8k2d7q'), 'RUP-8K2D7Q'); + expect(stripAppLink('0428-117', kCardLinkSegment), '0428-117'); + }); + + test('다른 기능의 링크는 코드로 읽지 않는다', () { + expect(parseCalendarCode('rup://app/diary-invite/RUP-8F3K2Q'), isNull); + expect(parseInviteCode('rup://app/calendar-invite/RUP-8K2D7Q'), isNull); + expect(parseCardCode('rup://app/diary-invite/RUP-8F3K2Q'), isNull); + }); + }); + + testWidgets('명함 링크로 열면 명함을 불러와 저장 확인 화면을 연다', (tester) async { + final cards = _CardRepo(); + final router = await _launch(tester, cards: cards); + + await _openLink(tester, router, petCardDeepLink('0428-117')); + + expect(cards.previewed, ['0428-117']); + expect(find.byType(ReceiveCardPage), findsOneWidget); + expect(find.text('초코의 명함'), findsOneWidget); + }); + + testWidgets('명함 코드가 아닌 링크는 서버에 묻지 않고 펫 명함 탭으로 보낸다', (tester) async { + final cards = _CardRepo(); + final router = await _launch(tester, cards: cards); + + await _openLink(tester, router, 'rup://app/pet-card/hello'); + + expect(cards.previewed, isEmpty); + expect(find.text('명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.'), findsOneWidget); + expect(find.text('우리 아이 명함'), findsOneWidget); + }); + + testWidgets('일정 연동 링크로 열면 확인한 뒤 연동하고 함께 관리 화면을 연다', (tester) async { + final sharing = _SharingRepo(); + final router = await _launch(tester, sharing: sharing); + + await _openLink(tester, router, calendarInviteLink('RUP-DAD123')); + expect(find.text('일정을 공유할까요?'), findsOneWidget); + await tester.tap(find.text('공유하기')); + await settle(tester); + await settle(tester); + + expect(sharing.joined, ['RUP-DAD123']); + expect(find.byType(ShareCalendarPage), findsOneWidget); + expect(find.text('2/4'), findsOneWidget); + }); +}