generated from ttipo/project-base
feat(flutter): 앱 링크 — 기기 카메라로 찍은 QR 로 바로 받기·참여·연동
QR 을 앱 스캐너가 아니라 기기 카메라로 찍어도 앱이 열려 이어지게 한다. - rup://app/<경로> 링크 (Android intent-filter · iOS URL scheme) — Android 는 호스트를 버리고 경로만 넘기므로 경로로 구분한다 - 명함 → 저장 확인 화면, 다이어리 초대 → 참여 확인, 일정 초대 → 연동 확인 (끝나면 그 기능의 탭으로) - QR 에 담는 주소를 새 형식으로 바꾸고, 예전 형식(rup://pet-card/…)도 계속 읽는다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -96,7 +96,8 @@ Future<void> 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<ScanOutcome<DiaryInvitation>>(AppRoutes.diaryJoinScan);
|
||||
switch (outcome) {
|
||||
@@ -109,19 +110,20 @@ Future<void> startJoiningDiary(BuildContext context, WidgetRef ref) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (context.mounted) await _confirmJoin(context, ref, invitation);
|
||||
if (context.mounted) await confirmDiaryJoin(context, ref, invitation);
|
||||
}
|
||||
|
||||
/// 참여 전 확인 — 이미 함께 쓰거나 자리가 없으면 서버와 같은 안내로 멈춘다
|
||||
Future<void> _confirmJoin(BuildContext context, WidgetRef ref, DiaryInvitation invitation) async {
|
||||
/// 참여 전 확인 — 이미 함께 쓰거나 자리가 없으면 서버와 같은 안내로 멈춘다 (앱 링크로 열 때도 쓴다)
|
||||
/// 참여했으면 true
|
||||
Future<bool> 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<void> _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 스캔 / 또는 / 초대 코드 입력 + 참여하기
|
||||
|
||||
@@ -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<LinkLandingPage> createState() => _LinkLandingPageState();
|
||||
}
|
||||
|
||||
class _LinkLandingPageState extends ConsumerState<LinkLandingPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 첫 화면을 그린 뒤 시작한다 — 확인 창은 이 화면 위에 뜬다
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_open());
|
||||
});
|
||||
}
|
||||
|
||||
ToastNotifier get _toast => ref.read(toastProvider.notifier);
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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',
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> 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<ScanOutcome<SharedCard>>(AppRoutes.petCardScan);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -116,11 +116,27 @@ Future<void> startCalendarLink(BuildContext context, WidgetRef ref) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (context.mounted) await _confirmLink(context, ref, invitation);
|
||||
if (context.mounted) await confirmCalendarLink(context, ref, invitation);
|
||||
}
|
||||
|
||||
/// 연동 전 확인 — 내 코드·이미 연동·가득 참은 서버와 같은 안내로 멈춘다
|
||||
Future<void> _confirmLink(BuildContext context, WidgetRef ref, CalendarInvitation invitation) async {
|
||||
/// 받은 링크로 연동할 수 있는지 먼저 확인한다 — 기기 저장 모드·이미 다른 사람과 연동이면 안내하고 false
|
||||
Future<bool> 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<bool> 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<void> _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<void> _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 스캔 / 코드 입력 + 안내 + 연동하기
|
||||
|
||||
Reference in New Issue
Block a user