diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist
index c9fe87d..151c9b7 100644
--- a/flutter/ios/Runner/Info.plist
+++ b/flutter/ios/Runner/Info.plist
@@ -8,6 +8,8 @@
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
Tma App
+ NSCameraUsageDescription
+ 다른 반려인의 펫 명함 QR 코드를 읽기 위해 카메라를 사용합니다.
NSPhotoLibraryAddUsageDescription
펫 명함 이미지를 사진 보관함에 저장합니다.
NSPhotoLibraryUsageDescription
diff --git a/flutter/lib/core/api/server_file.dart b/flutter/lib/core/api/server_file.dart
index 9605766..6602966 100644
--- a/flutter/lib/core/api/server_file.dart
+++ b/flutter/lib/core/api/server_file.dart
@@ -3,6 +3,10 @@
/// 서버에 올린 파일 표시 — `server-file:<파일 id>`
const String kServerFilePrefix = 'server-file:';
+/// 서버의 다른 경로에서 받는 사진 — `server-url:/shared-pet-cards/0428-117/images/pet`
+/// (내 파일이 아니라 파일 id 를 받지 못한 경우: 받기 전 명함 미리보기 등)
+const String kServerUrlPrefix = 'server-url:';
+
/// 앱에 들어 있는 그림 (명함 기본 배경 등)
const String kBundledAssetPrefix = 'assets/';
@@ -18,3 +22,15 @@ String? serverFileRefOrNull(String? fileId) => fileId == null ? null : serverFil
/// 앱에 들어 있는 그림인지
bool isBundledAsset(String path) => path.startsWith(kBundledAssetPrefix);
+
+/// 서버 경로(baseUrl 아래) → 사진 경로 자리에 넣을 값
+String serverUrlRef(String apiPath) => '$kServerUrlPrefix$apiPath';
+
+/// 사진을 받아 올 서버 경로 — 서버 파일이면 `/files/`, 서버 경로면 그 경로, 아니면 null(기기 사진)
+String? serverPathOf(String? path) {
+ if (path == null) return null;
+ final fileId = serverFileIdOf(path);
+ if (fileId != null) return '/files/$fileId';
+ if (path.startsWith(kServerUrlPrefix)) return path.substring(kServerUrlPrefix.length);
+ return null;
+}
diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart
index 300f65c..97293c6 100644
--- a/flutter/lib/core/router/app_router.dart
+++ b/flutter/lib/core/router/app_router.dart
@@ -26,10 +26,14 @@ import 'package:tma_app/features/notifications/presentation/pages/notifications_
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.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/features/pet_card/presentation/pages/pet_card_detail_page.dart';
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart';
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_manage_page.dart';
import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_page.dart';
+import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart';
+import 'package:tma_app/features/pet_card/presentation/pages/received_card_detail_page.dart';
import 'package:tma_app/features/records/presentation/pages/records_page.dart';
import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart';
import 'package:tma_app/features/shell/presentation/pages/main_shell.dart';
@@ -126,12 +130,30 @@ final appRouterProvider = Provider((ref) {
),
],
),
- // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다
+ // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new·scan…)를 :cardId 보다 먼저 둔다
GoRoute(
path: AppRoutes.petCardManage,
parentNavigatorKey: rootKey,
builder: (context, state) => const PetCardManagePage(),
),
+ GoRoute(
+ path: AppRoutes.petCardScan,
+ parentNavigatorKey: rootKey,
+ pageBuilder: (context, state) => _fadePage(state, const CardScannerPage()),
+ ),
+ GoRoute(
+ path: AppRoutes.petCardReceive,
+ parentNavigatorKey: rootKey,
+ // 찾은 명함 없이 들어오면(주소로 직접 등) 펫 명함 탭으로
+ redirect: (context, state) => state.extra is SharedCard ? null : AppRoutes.petCards,
+ builder: (context, state) => ReceiveCardPage(card: state.extra! as SharedCard),
+ ),
+ GoRoute(
+ path: AppRoutes.receivedCardPattern,
+ parentNavigatorKey: rootKey,
+ builder: (context, state) =>
+ ReceivedCardDetailPage(receivedId: state.pathParameters['receivedId']!),
+ ),
GoRoute(
path: AppRoutes.petCardCreatePattern,
parentNavigatorKey: rootKey,
diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart
index fd6a206..a855f0f 100644
--- a/flutter/lib/core/router/app_routes.dart
+++ b/flutter/lib/core/router/app_routes.dart
@@ -61,6 +61,12 @@ abstract final class AppRoutes {
static String petCardDetail(String cardId) => '/pet-cards/$cardId';
static String petCardEdit(String cardId) => '/pet-cards/$cardId/edit';
+ // 명함 받기 — 스캐너 · 저장 확인(찾은 명함을 extra 로 넘긴다) · 보관함의 받은 명함
+ static const String petCardScan = '/pet-cards/scan';
+ static const String petCardReceive = '/pet-cards/receive';
+ static const String receivedCardPattern = '/pet-cards/received/:receivedId';
+ static String receivedCard(String receivedId) => '/pet-cards/received/$receivedId';
+
/// 로그인 전에만 머무를 수 있는 화면
static const Set _signedOutOnly = {intro, login};
diff --git a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart
index 1e57d31..3c0e28c 100644
--- a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart
+++ b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart
@@ -75,7 +75,7 @@ class _HomePetsSectionState extends ConsumerState {
child: NameCard(
pet: selected,
style: card.style,
- onShare: () => unawaited(sharePetCard(context, pet: selected, card: card, capture: _capture)),
+ onShare: () => unawaited(sharePetCard(context, ref, pet: selected, card: card, capture: _capture)),
),
),
),
diff --git a/flutter/lib/features/pet_card/data/api_pet_card_repository.dart b/flutter/lib/features/pet_card/data/api_pet_card_repository.dart
index e24c24f..8a6eb5e 100644
--- a/flutter/lib/features/pet_card/data/api_pet_card_repository.dart
+++ b/flutter/lib/features/pet_card/data/api_pet_card_repository.dart
@@ -6,6 +6,7 @@ import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/features/pet_card/data/pet_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/pet_card.dart';
/// 펫 명함 경로 (backend PetCardsController · RepresentativeCardController)
@@ -100,6 +101,10 @@ class ApiPetCardRepository implements PetCardRepository {
@override
Future clearRepresentative(String petId) => _dio.delete(_representativePath(petId));
+ @override
+ Future setShareMode(String cardId, CardViewMode mode) =>
+ _dio.put('$_kCardsPath/$cardId/share-mode', data: {'mode': mode.name});
+
/// 서버가 아이를 지우면서 명함도 함께 지웠다
@override
Future dropPet(String petId) async {}
diff --git a/flutter/lib/features/pet_card/data/pet_card_repository.dart b/flutter/lib/features/pet_card/data/pet_card_repository.dart
index 583271b..f3560c7 100644
--- a/flutter/lib/features/pet_card/data/pet_card_repository.dart
+++ b/flutter/lib/features/pet_card/data/pet_card_repository.dart
@@ -9,6 +9,7 @@ import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet_card/data/api_pet_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/pet_card.dart';
/// 내 명함 + 아이별 대표 명함 (petId → cardId)
@@ -29,6 +30,9 @@ abstract interface class PetCardRepository {
Future clearRepresentative(String petId);
+ /// 공유 시트를 열 때 — 받는 사람이 볼 모양(간략히·상세히). 기기 저장 모드는 할 일이 없다
+ Future setShareMode(String cardId, CardViewMode mode);
+
/// 아이를 지웠을 때 그 아이의 명함을 지운다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 명함도 함께 지웠다.
Future dropPet(String petId);
@@ -96,6 +100,9 @@ class LocalPetCardRepository implements PetCardRepository {
@override
Future clearRepresentative(String petId) => _writeRep(_readRep()..remove(petId));
+ @override
+ Future setShareMode(String cardId, CardViewMode mode) async {}
+
@override
Future dropPet(String petId) async {
await _write(_read().where((c) => c.petId != petId).toList());
diff --git a/flutter/lib/features/pet_card/data/received_card_repository.dart b/flutter/lib/features/pet_card/data/received_card_repository.dart
new file mode 100644
index 0000000..615462d
--- /dev/null
+++ b/flutter/lib/features/pet_card/data/received_card_repository.dart
@@ -0,0 +1,177 @@
+import 'package:dio/dio.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:tma_app/core/api/api_paging.dart';
+import 'package:tma_app/core/api/dio_provider.dart';
+import 'package:tma_app/core/api/server_file.dart';
+import 'package:tma_app/core/api/session_store.dart';
+import 'package:tma_app/core/config/app_config.dart';
+import 'package:tma_app/core/utils/color_utils.dart';
+import 'package:tma_app/core/utils/date_format.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';
+import 'package:tma_app/features/pet_card/domain/received_card.dart';
+
+/// 명함 받기 경로 (backend SharedPetCardsController · ReceivedCardsController)
+const String _kSharedPath = '/shared-pet-cards';
+const String _kReceivedPath = '/received-cards';
+
+/// 받은 명함 저장소 계약 — 다른 회원의 명함을 받으므로 서버가 있어야 한다
+abstract interface class ReceivedCardRepository {
+ /// 명함 받기를 쓸 수 있는지 — 기기 저장 모드에서는 받을 명함이 없다
+ bool get available;
+
+ /// 보관함 — 최근에 받은 명함이 앞
+ Future> fetch();
+
+ /// 받기 전 미리보기
+ Future preview(String code);
+
+ /// 보관함에 저장
+ Future receive(String code, {String? note});
+
+ Future delete(String id);
+}
+
+/// 기기 저장 모드 — 명함 받기를 쓸 수 없다 (화면은 안내만 띄운다)
+class UnavailableReceivedCardRepository implements ReceivedCardRepository {
+ const UnavailableReceivedCardRepository();
+
+ @override
+ bool get available => false;
+
+ @override
+ Future> fetch() async => const [];
+
+ @override
+ Future preview(String code) => throw StateError('명함 받기는 서버 모드에서만 쓸 수 있다');
+
+ @override
+ Future receive(String code, {String? note}) =>
+ throw StateError('명함 받기는 서버 모드에서만 쓸 수 있다');
+
+ @override
+ Future delete(String id) async {}
+}
+
+/// 공유된 아이 정보 → 명함 위젯이 그리는 Pet (간략히면 건강 정보·병원·메모가 비어 있다)
+Pet sharedPetFromApi(String id, Map json, {String? photoPath}) => Pet(
+ id: id,
+ tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first,
+ name: json['name']! as String,
+ species: json['species']! as String,
+ breed: json['breed'] as String? ?? '',
+ gender: PetGender.values.asNameMap()[json['gender']] ?? PetGender.male,
+ neutered: json['neutered'] as bool? ?? false,
+ birthDate: parseIsoDate(json['birthDate']! as String),
+ weight: (json['weight']! as num).toDouble(),
+ weightUnit: WeightUnit.values.asNameMap()[json['weightUnit']] ?? WeightUnit.kg,
+ photoPath: photoPath,
+ microchip: json['microchip'] as String?,
+ bloodType: json['bloodType'] as String?,
+ vet: json['vet'] as String?,
+ memo: json['memo'] as String?,
+ allergies: _strings(json['allergies']),
+ conditions: _strings(json['conditions']),
+ watchConditions: _strings(json['watchConditions']),
+ );
+
+List _strings(Object? value) => value is List ? value.whereType().toList() : const [];
+
+/// 공유된 꾸미기 값 → CardStyle ([backgroundPath] 는 배경 사진이 있을 때)
+CardStyle sharedStyleFromApi(Map json, {String? backgroundPath}) {
+ final fallback = CardStyle.initial();
+ return CardStyle(
+ tint: colorFromHex(json['tint'] as String?) ?? fallback.tint,
+ tint2: colorFromHex(json['tint2'] as String?) ?? fallback.tint2,
+ nameBg: colorFromHex(json['nameBg'] as String?),
+ nameBgImage: (json['nameBgAsset'] as String?) ?? backgroundPath,
+ textLight: json['textLight'] as bool? ?? false,
+ scrim: json['scrim'] as bool? ?? false,
+ scrimTone: ScrimTone.values.asNameMap()[json['scrimTone']] ?? ScrimTone.light,
+ );
+}
+
+/// 받기 전 미리보기 — 사진은 아직 내 것이 아니라 명함 코드 경로로 받는다
+SharedCard sharedCardPreviewFromApi(Map json) {
+ final code = json['code']! as String;
+ String? image(String flag, String kind) =>
+ json[flag] == true ? serverUrlRef('$_kSharedPath/$code/images/$kind') : null;
+ return SharedCard(
+ code: code,
+ mode: CardViewMode.byName(json['mode'] as String?),
+ senderNickname: json['senderNickname'] as String?,
+ senderPhotoPath: image('hasSenderPhoto', 'sender'),
+ pet: sharedPetFromApi('shared-$code', json['pet']! as Map, photoPath: image('hasPetPhoto', 'pet')),
+ style: sharedStyleFromApi(
+ json['style']! as Map,
+ backgroundPath: image('hasBackgroundPhoto', 'background'),
+ ),
+ );
+}
+
+/// 보관함의 받은 명함 — 사진은 내 소유 사본(server-file:)
+ReceivedCard receivedCardFromApi(Map json) {
+ final id = json['id']! as String;
+ return ReceivedCard(
+ id: id,
+ code: json['code']! as String,
+ mode: CardViewMode.byName(json['mode'] as String?),
+ senderNickname: json['senderNickname'] as String?,
+ senderPhotoPath: serverFileRefOrNull(json['senderPhotoFileId'] as String?),
+ pet: sharedPetFromApi(
+ 'received-$id',
+ json['pet']! as Map,
+ photoPath: serverFileRefOrNull(json['petPhotoFileId'] as String?),
+ ),
+ style: sharedStyleFromApi(
+ json['style']! as Map,
+ backgroundPath: serverFileRefOrNull(json['backgroundFileId'] as String?),
+ ),
+ note: json['note'] as String?,
+ receivedAt: DateTime.parse(json['receivedAt']! as String).toLocal(),
+ );
+}
+
+/// 서버 저장소
+class ApiReceivedCardRepository implements ReceivedCardRepository {
+ ApiReceivedCardRepository({required Dio dio, required SessionStore session})
+ : _dio = dio,
+ _session = session;
+
+ final Dio _dio;
+ final SessionStore _session;
+
+ @override
+ bool get available => true;
+
+ @override
+ Future> fetch() async {
+ if (!_session.hasSession) return const [];
+ final cards = await fetchAllPages(_dio, _kReceivedPath, receivedCardFromApi);
+ // 시안: 새로 받은 명함이 위로
+ return cards..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
+ }
+
+ @override
+ Future preview(String code) async {
+ final response = await _dio.get