feat(flutter): 명함 받기 — QR·코드로 받아 보관함에 저장

다른 회원이 공유한 펫 명함을 받아 보관할 수 있게 한다.
- 스캐너(mobile_scanner) 또는 명함 코드 입력으로 명함을 찾고, 저장 전 확인 화면에서 메모를 남긴다
- 보관함 목록·검색·상세(보낸 사람이 공유한 모양 그대로)·삭제
- 공유 시트를 열 때 지금 보이는 모양(간략히·상세히)을 서버에 알린다
- 받기 전 사진은 명함 코드 경로(server-url:)로, 받은 뒤에는 내 소유 사본(server-file:)으로 그린다
- 기기 저장 모드에서는 받을 명함이 없으므로 안내만 띄운다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 19:55:39 +09:00
parent f045e1599f
commit 7701be3795
29 changed files with 2056 additions and 88 deletions
@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/data/received_card_repository.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 'support/fake_api.dart';
Map<String, Object?> _petJson({bool detail = false}) => {
'name': '초코',
'species': '강아지',
'breed': '닥스훈트',
'gender': 'male',
'birthDate': '2021-05-14',
'weight': 7,
'weightUnit': 'kg',
'microchip': null,
'tint': '#C9794A',
if (detail) ...{
'neutered': true,
'bloodType': 'DEA 1.1-',
'vet': '드림 동물병원',
'memo': '공놀이를 좋아해요',
'allergies': <String>[],
'conditions': <String>[],
'watchConditions': ['허리'],
},
};
const Map<String, Object?> _styleJson = {
'tint': '#C9794A',
'tint2': '#E2A878',
'nameBg': null,
'nameBgAsset': null,
'textLight': false,
'scrim': false,
'scrimTone': 'light',
};
Map<String, Object?> _receivedJson({
String id = 'r1',
String receivedAt = '2026-10-10T03:00:00.000Z',
String? note,
String mode = 'simple',
String? petPhotoFileId,
}) =>
{
'id': id,
'code': '0428-117',
'mode': mode,
'senderNickname': '초코아빠',
'senderPhotoFileId': null,
'pet': _petJson(detail: mode == 'detail'),
'petPhotoFileId': petPhotoFileId,
'style': _styleJson,
'backgroundFileId': null,
'note': note,
'receivedAt': receivedAt,
};
void main() {
group('parseCardCode', () {
test('QR 주소·코드·하이픈 없는 7자리를 모두 명함 코드로 읽는다', () {
expect(parseCardCode('rup://pet-card/0428-117'), '0428-117');
expect(parseCardCode(' 0428-117 '), '0428-117');
expect(parseCardCode('0428117'), '0428-117');
});
test('명함 코드가 아니면 null', () {
expect(parseCardCode('https://example.com'), isNull);
expect(parseCardCode('0428-11'), isNull);
expect(parseCardCode('rup://pet-card/../files'), isNull);
});
});
group('ApiReceivedCardRepository', () {
late FakeApi api;
late ApiReceivedCardRepository repo;
setUp(() {
api = FakeApi();
repo = ApiReceivedCardRepository(dio: api.dio, session: api.session);
});
test('미리보기 사진은 명함 코드 경로로 받는다 (아직 내 파일이 아니다)', () async {
api.on('GET', '/shared-pet-cards/:code', (r) => ok({
'code': r.params['code'],
'mode': 'detail',
'senderNickname': null,
'hasSenderPhoto': false,
'pet': _petJson(detail: true),
'hasPetPhoto': true,
'style': _styleJson,
'hasBackgroundPhoto': true,
}));
final card = await repo.preview('0428-117');
expect(card.mode, CardViewMode.detail);
expect(card.senderName, kUnknownSender);
expect(card.senderPhotoPath, isNull);
expect(card.pet.photoPath, 'server-url:/shared-pet-cards/0428-117/images/pet');
expect(card.style.nameBgImage, 'server-url:/shared-pet-cards/0428-117/images/background');
expect(card.pet.vet, '드림 동물병원');
expect(card.pet.watchConditions, ['허리']);
});
test('보관함은 최근에 받은 명함이 앞이고, 간략히 명함은 건강 정보가 비어 있다', () async {
api.on('GET', '/received-cards', (r) => ok(page([
_receivedJson(),
_receivedJson(id: 'r2', receivedAt: '2026-10-11T03:00:00.000Z', petPhotoFileId: 'f9'),
])));
final cards = await repo.fetch();
expect(cards.map((c) => c.id), ['r2', 'r1']);
expect(cards.first.pet.photoPath, 'server-file:f9');
expect(cards.last.mode, CardViewMode.simple);
expect(cards.last.pet.vet, isNull);
expect(cards.last.pet.neutered, isFalse);
});
test('받을 때 코드와 메모를 보낸다', () async {
api.on('POST', '/received-cards', (r) {
expect(r.json, {'code': '0428-117', 'note': '산책 친구'});
return ok(_receivedJson(note: '산책 친구'), status: 201);
});
final card = await repo.receive('0428-117', note: '산책 친구');
expect(card.note, '산책 친구');
expect(card.senderName, '초코아빠');
});
test('보관함에서 지운다', () async {
api.on('DELETE', '/received-cards/:id', (r) => noContent);
await repo.delete('r1');
expect(api.sent('DELETE', '/received-cards/r1'), hasLength(1));
});
test('로그인 전에는 보관함을 묻지 않는다', () async {
final signedOut = FakeApi(tokens: null);
final cards = await ApiReceivedCardRepository(dio: signedOut.dio, session: signedOut.session).fetch();
expect(cards, isEmpty);
expect(signedOut.requests, isEmpty);
});
});
test('공유 시트를 열면 받는 사람이 볼 모양을 서버에 알린다', () async {
final api = FakeApi();
api.on('PUT', '/pet-cards/:id/share-mode', (r) {
expect(r.params['id'], 'c1');
expect(r.json, {'mode': 'detail'});
return noContent;
});
final repo = ApiPetCardRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
await repo.setShareMode('c1', CardViewMode.detail);
expect(api.sent('PUT', '/pet-cards/c1/share-mode'), hasLength(1));
});
}
+235
View File
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/api_exception.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/card_scanner_page.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'support/app_harness.dart';
const String _kCode = '0428-117';
SharedCard _shared({CardViewMode mode = CardViewMode.simple}) => SharedCard(
code: _kCode,
mode: mode,
senderNickname: '초코아빠',
senderPhotoPath: null,
pet: samplePet(id: 'shared', name: '초코'),
style: CardStyle.initial(),
);
ReceivedCard _received(
String id, {
String name = '초코',
String sender = '초코아빠',
String? note,
CardViewMode mode = CardViewMode.simple,
}) =>
ReceivedCard(
id: id,
code: _kCode,
mode: mode,
senderNickname: sender,
senderPhotoPath: null,
pet: samplePet(id: 'received-$id', name: name),
style: CardStyle.initial(),
note: note,
receivedAt: DateTime(2026, 10, 9),
);
/// 서버 없이 받은 명함을 기억하는 저장소
class _FakeReceivedRepo implements ReceivedCardRepository {
_FakeReceivedRepo({List<ReceivedCard> cards = const [], this.failure}) : cards = [...cards];
final List<ReceivedCard> cards;
/// 미리보기를 이 코드로 실패시킨다 (인터셉터가 알린 실패처럼)
final String? failure;
final previewed = <String>[];
String? savedNote;
@override
bool get available => true;
@override
Future<List<ReceivedCard>> fetch() async => [...cards];
@override
Future<SharedCard> preview(String code) async {
previewed.add(code);
if (failure case final code?) throw ApiException.fromCode(code);
return _shared();
}
@override
Future<ReceivedCard> receive(String code, {String? note}) async {
savedNote = note;
final card = _received('new', note: note);
cards.insert(0, card);
return card;
}
@override
Future<void> delete(String id) async => cards.removeWhere((c) => c.id == id);
}
/// 실제 카메라 대신 — 누르면 QR 을 읽은 것처럼 알린다
Widget _fakeCamera(ValueChanged<String> onCode) => Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 120),
child: TextButton(onPressed: () => onCode('rup://pet-card/$_kCode'), child: const Text('QR 비추기')),
),
);
Future<void> _openCardsTab(WidgetTester tester, ReceivedCardRepository repo) async {
await launchSignedIn(
tester,
overrides: [
receivedCardRepositoryProvider.overrideWithValue(repo),
scannerCameraProvider.overrideWithValue(_fakeCamera),
],
);
await tester.tap(find.bySemanticsLabel('펫 명함'));
await settle(tester);
}
Future<void> _openScanner(WidgetTester tester) async {
await tester.ensureVisible(find.text('명함 받기'));
await tester.tap(find.text('명함 받기'));
await settle(tester);
}
void main() {
testWidgets('보관함은 받은 명함 수와 보낸 사람을 보여 주고 검색으로 거른다', (tester) async {
await _openCardsTab(
tester,
_FakeReceivedRepo(cards: [_received('r1'), _received('r2', name: '나비', sender: '나비집사')]),
);
expect(find.text('보관된 명함 2'), findsOneWidget);
expect(find.text('초코'), findsOneWidget);
expect(find.textContaining('님이 보냈어요.'), findsNWidgets(2));
// 돋보기를 누르면 검색창이 펼쳐진다
await tester.tap(find.byWidgetPredicate((w) => w is RupIcon && w.name == RupIconName.search));
await settle(tester);
await tester.enterText(find.byType(TextField), '나비집사');
await settle(tester);
expect(find.text('나비'), findsOneWidget);
expect(find.text('초코'), findsNothing);
await tester.enterText(find.byType(TextField), '보리');
await settle(tester);
expect(find.text("'보리' 검색 결과가 없어요."), findsOneWidget);
});
testWidgets('QR 을 비추면 명함을 불러와 메모와 함께 보관함에 저장한다', (tester) async {
final repo = _FakeReceivedRepo();
await _openCardsTab(tester, repo);
await _openScanner(tester);
expect(find.text('상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'), findsOneWidget);
await tester.tap(find.text('QR 비추기'));
await tester.pump();
expect(find.text('명함을 찾았어요! 정보 불러오는 중…'), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
await settle(tester);
expect(repo.previewed, [_kCode]);
expect(find.text('초코의 명함'), findsOneWidget);
expect(find.text('초코아빠님'), findsOneWidget);
await tester.enterText(find.byType(TextField), '산책 친구');
await tester.pump();
expect(find.text('5/200'), findsOneWidget);
await tester.ensureVisible(find.text('보관함에 저장'));
await tester.tap(find.text('보관함에 저장'));
await settle(tester);
expect(repo.savedNote, '산책 친구');
expect(find.text('초코 명함을 보관함에 저장했어요'), findsOneWidget);
expect(find.text('보관된 명함 1'), findsOneWidget);
});
testWidgets('명함 코드를 직접 입력해도 받을 수 있다', (tester) async {
final repo = _FakeReceivedRepo();
await _openCardsTab(tester, repo);
await _openScanner(tester);
await tester.tap(find.text('명함 코드 입력하기'));
await settle(tester);
expect(find.text('명함코드 입력하기'), findsOneWidget);
await tester.enterText(find.byType(TextField), '0428117');
await tester.pump();
await tester.tap(find.text('보관함에 저장'));
await settle(tester);
expect(repo.previewed, [_kCode]);
expect(find.text('초코의 명함'), findsOneWidget);
});
testWidgets('명함 코드 모양이 아니면 서버에 묻지 않고 알린다', (tester) async {
final repo = _FakeReceivedRepo();
await _openCardsTab(tester, repo);
await _openScanner(tester);
await tester.tap(find.text('명함 코드 입력하기'));
await settle(tester);
await tester.enterText(find.byType(TextField), 'PET-1A2B');
await tester.pump();
await tester.tap(find.text('보관함에 저장'));
await settle(tester);
expect(repo.previewed, isEmpty);
expect(find.text('명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.'), findsOneWidget);
expect(find.text('명함코드 입력하기'), findsOneWidget);
});
testWidgets('명함을 받지 못하면 스캐너로 돌아가 다시 비출 수 있다', (tester) async {
final repo = _FakeReceivedRepo(failure: 'CARD_003');
await _openCardsTab(tester, repo);
await _openScanner(tester);
await tester.tap(find.text('QR 비추기'));
await settle(tester);
expect(repo.previewed, [_kCode]);
expect(find.text('상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'), findsOneWidget);
expect(find.text('명함 코드 입력하기'), findsOneWidget);
});
testWidgets('받은 명함은 보낸 모양대로 보이고 보관함에서 지울 수 있다', (tester) async {
final repo = _FakeReceivedRepo(cards: [_received('r1')]);
await _openCardsTab(tester, repo);
await tester.ensureVisible(find.text('초코'));
await tester.tap(find.text('초코'));
await settle(tester);
expect(find.text('받은 명함 · 초코아빠님'), findsOneWidget);
// 간략히로 공유된 명함 — 상세 명함(건강 정보)은 없다
expect(find.text('건강 정보'), findsNothing);
expect(find.text('남긴 메모가 없어요.'), findsOneWidget);
await tester.ensureVisible(find.text('보관함에서 삭제'));
await tester.tap(find.text('보관함에서 삭제'));
await settle(tester);
expect(repo.cards, isEmpty);
expect(find.text('보관함에서 삭제했어요'), findsOneWidget);
expect(find.text('받은 명함이 없어요.'), findsOneWidget);
});
testWidgets('기기 저장 모드에서는 명함 받기 대신 안내를 띄운다', (tester) async {
await _openCardsTab(tester, const UnavailableReceivedCardRepository());
await _openScanner(tester);
expect(find.text('기기 저장 모드에서는 명함을 받을 수 없어요'), findsOneWidget);
expect(find.text('명함 코드 입력하기'), findsNothing);
});
}