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:
2026-10-11 22:50:19 +09:00
parent b10e90fe5c
commit 3f84d7b747
12 changed files with 446 additions and 35 deletions
+171
View File
@@ -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 = <String>[];
@override
bool get available => true;
@override
Future<List<ReceivedCard>> fetch() async => const [];
@override
Future<SharedCard> 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<ReceivedCard> receive(String code, {String? note}) => throw UnimplementedError();
@override
Future<void> delete(String id) async {}
}
/// 함께 관리 — 연동 전, 받은 코드는 콩이아빠의 것
class _SharingRepo implements CalendarSharingRepository {
bool linked = false;
final joined = <String>[];
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<CalendarSharing?> fetch({bool quiet = false}) async {
return _sharing;
}
@override
Future<CalendarInvitation> previewInvitation(String code) async => CalendarInvitation(
code: code,
ownerNickname: '콩이아빠',
memberCount: 1,
maxMembers: 4,
isMine: false,
isMember: false,
);
@override
Future<CalendarSharing> join(String code) async {
joined.add(code);
linked = true;
return _sharing;
}
@override
Future<void> leave() async => linked = false;
}
Future<GoRouter> _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<void> _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);
});
}