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);
+ });
+}