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>('$_kSharedPath/$code'); + return sharedCardPreviewFromApi(response.data!); + } + + @override + Future receive(String code, {String? note}) async { + final response = await _dio.post>(_kReceivedPath, data: {'code': code, 'note': note}); + return receivedCardFromApi(response.data!); + } + + @override + Future delete(String id) => _dio.delete('$_kReceivedPath/$id'); +} + +final receivedCardRepositoryProvider = Provider((ref) { + return switch (ref.watch(dataModeProvider)) { + DataMode.local => const UnavailableReceivedCardRepository(), + DataMode.api => ApiReceivedCardRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)), + }; +}); diff --git a/flutter/lib/features/pet_card/domain/card_view_mode.dart b/flutter/lib/features/pet_card/domain/card_view_mode.dart new file mode 100644 index 0000000..d906f81 --- /dev/null +++ b/flutter/lib/features/pet_card/domain/card_view_mode.dart @@ -0,0 +1,13 @@ +/// 명함을 보는 모양 — 간략히(명함) / 상세히(명함 + 건강 정보·병원·메모). +/// 공유할 때도 이 모양으로 간다 — 받는 사람은 공유된 모양의 정보만 받는다. +enum CardViewMode { + simple('간략히'), + detail('상세히'); + + const CardViewMode(this.label); + + final String label; + + static CardViewMode byName(String? name) => + CardViewMode.values.where((m) => m.name == name).firstOrNull ?? CardViewMode.simple; +} diff --git a/flutter/lib/features/pet_card/domain/received_card.dart b/flutter/lib/features/pet_card/domain/received_card.dart new file mode 100644 index 0000000..ad99873 --- /dev/null +++ b/flutter/lib/features/pet_card/domain/received_card.dart @@ -0,0 +1,80 @@ +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'; + +/// 받은 명함 메모 최대 길이 (시안 maxLength 200) +const int kReceivedNoteMaxLength = 200; + +/// 다른 회원이 공유한 명함 — 받기 전 미리보기와 보관함의 받은 명함이 같은 모양을 쓴다. +/// [pet] 은 공유된 모양에 담긴 정보만 채워져 있다 (간략히면 건강 정보·병원·메모가 비어 있다). +class SharedCard { + const SharedCard({ + required this.code, + required this.mode, + required this.senderNickname, + required this.senderPhotoPath, + required this.pet, + required this.style, + }); + + final String code; + + /// 보낸 사람이 공유한 모양 — 이 모양으로만 보여 준다 + final CardViewMode mode; + + /// 보낸 사람 닉네임 — 모르면 null + final String? senderNickname; + final String? senderPhotoPath; + final Pet pet; + final CardStyle style; + + /// "초코아빠" — 닉네임이 없으면 기본 호칭 + String get senderName => senderNickname ?? kUnknownSender; +} + +/// 보낸 사람의 닉네임을 모를 때 +const String kUnknownSender = '집사'; + +/// 보관함에 저장한 받은 명함 — 받는 순간의 사본이다 +class ReceivedCard extends SharedCard { + const ReceivedCard({ + required this.id, + required super.code, + required super.mode, + required super.senderNickname, + required super.senderPhotoPath, + required super.pet, + required super.style, + required this.note, + required this.receivedAt, + }); + + final String id; + + /// 받은 사람이 남긴 메모 + final String? note; + final DateTime receivedAt; + + /// 보관함 검색 — 아이 이름·보낸 사람 (시안: name + from) + bool matches(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + return '${pet.name} ${senderNickname ?? ''}'.toLowerCase().contains(q); + } +} + +/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽는다 (시스템 카메라로 찍으면 앱을 연다) +String petCardDeepLink(String code) => 'rup://pet-card/$code'; + +final RegExp _kCardCode = RegExp(r'^\d{4}-\d{3}$'); + +/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://pet-card/0428-117)·코드(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); + 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/card_scanner_page.dart b/flutter/lib/features/pet_card/presentation/pages/card_scanner_page.dart new file mode 100644 index 0000000..5c968ae --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/card_scanner_page.dart @@ -0,0 +1,463 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.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/theme/app_typography.dart'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; +import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; + +// 시안 .rup-scanner 치수·색 +const Color _kScannerBg = Color(0xFF14110E); +const Color _kDim = Color(0x99120F0C); // rgba(18,15,12,.6) +const Color _kFoundGreen = Color(0xFF4ED08A); +const double _kFrame = 244; +const double _kFrameRadius = 22; +const double _kCorner = 36; +const double _kCornerStroke = 4; +const double _kCloseSize = 38; +const double _kCheckSize = 70; + +/// 스캔 줄이 위아래로 한 번 오가는 시간 (시안 2.3s) +const Duration _kSweep = Duration(milliseconds: 1150); + +/// 명함을 찾은 뒤 체크 표시를 보여 주는 최소 시간 (시안 820ms) +const Duration _kFoundHold = Duration(milliseconds: 820); + +/// 같은 QR 을 실패한 뒤 다시 읽기까지 기다리는 시간 — 같은 알림이 계속 뜨지 않게 +const Duration _kRetryGap = Duration(seconds: 3); + +/// 스캐너를 닫은 결과 +sealed class ScanOutcome { + const ScanOutcome(); +} + +/// QR 로 명함을 찾았다 +final class ScanFound extends ScanOutcome { + const ScanFound(this.card); + + final SharedCard card; +} + +/// "명함 코드 입력하기"를 눌렀다 +final class ScanTypeCode extends ScanOutcome { + const ScanTypeCode(); +} + +/// QR 을 읽어 알려 주는 카메라 화면 +typedef ScannerCamera = Widget Function(ValueChanged onCode); + +/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라) +final scannerCameraProvider = Provider((ref) => null); + +/// 명함 받기 스캐너 — 상대 명함의 QR 을 비추면 명함을 불러온다 +class CardScannerPage extends ConsumerStatefulWidget { + const CardScannerPage({super.key}); + + @override + ConsumerState createState() => _CardScannerPageState(); +} + +class _CardScannerPageState extends ConsumerState { + late final ScannerCamera? _fakeCamera = ref.read(scannerCameraProvider); + MobileScannerController? _controller; + bool _found = false; + bool _busy = false; + String? _lastRaw; + DateTime _lastAt = DateTime.fromMillisecondsSinceEpoch(0); + + @override + void initState() { + super.initState(); + if (_fakeCamera == null) _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]); + } + + @override + void dispose() { + unawaited(_controller?.dispose()); + // 다른 화면은 상태 표시줄 모양을 따로 정하지 않는다 — 어두운 글자(앱 기본)로 되돌린다 + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + final raw = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull; + if (raw != null) unawaited(_onCode(raw)); + } + + Future _onCode(String raw) async { + if (_busy) return; + final now = DateTime.now(); + if (raw == _lastRaw && now.difference(_lastAt) < _kRetryGap) return; + _lastRaw = raw; + _lastAt = now; + + final code = parseCardCode(raw); + if (code == null) { + // 펫 명함 QR 이 아니다 — 서버가 못 찾았을 때와 같은 안내 + ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert); + return; + } + _busy = true; + setState(() => _found = true); + final shown = Stopwatch()..start(); + try { + final card = await ref.read(receivedCardsProvider.notifier).preview(code); + final rest = _kFoundHold - shown.elapsed; + if (rest > Duration.zero) await Future.delayed(rest); + if (mounted) context.pop(ScanFound(card)); + } catch (e) { + if (!isReportedApiFailure(e)) rethrow; + // 실패 알림은 인터셉터가 띄웠다 — 다시 비출 수 있게 되돌린다 + _lastAt = DateTime.now(); + if (mounted) { + setState(() { + _found = false; + _busy = false; + }); + } + } + } + + Widget _camera() { + final camera = _fakeCamera; + if (camera != null) return camera(_onCode); + return MobileScanner( + controller: _controller, + onDetect: _onDetect, + // 카메라를 켜는 동안·쓸 수 없을 때는 시안의 어두운 바탕 (안내는 아래 문구가 맡는다) + placeholderBuilder: (context) => const _Feed(), + errorBuilder: (context, error) => const _Feed(), + ); + } + + @override + Widget build(BuildContext context) { + final top = MediaQuery.paddingOf(context).top; + final topBarHeight = top + 8 + _kCloseSize; + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: _kScannerBg, + body: Stack( + children: [ + Positioned.fill(child: _camera()), + Positioned.fill( + child: Column( + children: [ + SizedBox(height: topBarHeight), + Expanded( + child: Center( + child: _Frame(found: _found), + ), + ), + _Bottom( + found: _found, + camera: _controller, + onManual: () => context.pop(const ScanTypeCode()), + ), + ], + ), + ), + // 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다 + Positioned( + top: top + 8, + left: 18, + right: 18, + child: _TopBar(onClose: () => context.pop()), + ), + ], + ), + ), + ); + } +} + +/// 카메라 화면이 아직 없을 때의 바탕 (시안 .rup-scanner__feed) +class _Feed extends StatelessWidget { + const _Feed(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0, -.2), + radius: 1.1, + colors: [Color(0xFF3A332B), Color(0xFF221D17), _kScannerBg], + stops: [0, .56, 1], + ), + ), + ); + } +} + +class _TopBar extends StatelessWidget { + const _TopBar({required this.onClose}); + + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Semantics( + button: true, + label: '닫기', + child: Pressable( + pressedScale: .9, + onTap: onClose, + child: Container( + width: _kCloseSize, + height: _kCloseSize, + decoration: const BoxDecoration(color: Color(0x24FFFFFF), shape: BoxShape.circle), + alignment: Alignment.center, + child: const RupIcon(RupIconName.x, size: 20, stroke: 2.4, color: Colors.white), + ), + ), + ), + const Expanded( + child: Text( + '명함 받기', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 16, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: Colors.white, + ), + ), + ), + const SizedBox(width: _kCloseSize), + ], + ); + } +} + +/// 조준 프레임 — 바깥은 어둡게, 모서리 표시 + 스캔 줄, 찾으면 초록 체크 +class _Frame extends StatefulWidget { + const _Frame({required this.found}); + + final bool found; + + @override + State<_Frame> createState() => _FrameState(); +} + +class _FrameState extends State<_Frame> with SingleTickerProviderStateMixin { + late final AnimationController _sweep = AnimationController(vsync: this, duration: _kSweep) + ..repeat(reverse: true); + + @override + void dispose() { + _sweep.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final found = widget.found; + return SizedBox.square( + dimension: _kFrame, + child: CustomPaint( + painter: const _DimOutside(), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: TweenAnimationBuilder( + tween: ColorTween(end: found ? _kFoundGreen : AppColors.primary), + duration: const Duration(milliseconds: 300), + builder: (context, color, _) => CustomPaint(painter: _Corners(color ?? AppColors.primary)), + ), + ), + if (!found) + AnimatedBuilder( + animation: _sweep, + builder: (context, child) { + final t = Curves.easeInOut.transform(_sweep.value); + return Positioned(left: 10, right: 10, top: 12 + (_kFrame - 28) * t, child: child!); + }, + child: Container( + height: 2, + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0x00FF7500), AppColors.primary, Color(0x00FF7500)]), + boxShadow: [BoxShadow(color: Color(0xB3FF7500), blurRadius: 14, spreadRadius: 2)], + ), + ), + ), + Center( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: found ? 1 : 0, + child: AnimatedScale( + duration: const Duration(milliseconds: 400), + curve: const Cubic(.34, 1.5, .5, 1), + scale: found ? 1 : .5, + child: Container( + width: _kCheckSize, + height: _kCheckSize, + decoration: const BoxDecoration( + color: _kFoundGreen, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x804ED08A), offset: Offset(0, 10), blurRadius: 30)], + ), + alignment: Alignment.center, + child: const RupIcon(RupIconName.check, size: 36, stroke: 3, color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 프레임 바깥 화면 전체를 어둡게 (시안 box-shadow 0 0 0 2000px) +class _DimOutside extends CustomPainter { + const _DimOutside(); + + @override + void paint(Canvas canvas, Size size) { + const reach = 4000.0; + final outside = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Rect.fromLTRB(-reach, -reach, size.width + reach, size.height + reach)) + ..addRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kFrameRadius))); + canvas.drawPath(outside, Paint()..color = _kDim); + } + + @override + bool shouldRepaint(_DimOutside oldDelegate) => false; +} + +/// 네 모서리 표시 — 프레임 테두리를 따라 굵기 4, 길이 36 +class _Corners extends CustomPainter { + const _Corners(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _kCornerStroke + ..strokeCap = StrokeCap.butt; + const r = 16.0; // 시안 바깥 반경 18 − 굵기 절반 + const reach = _kCorner - _kCornerStroke / 2; + final w = size.width; + final h = size.height; + final rect = Rect.fromCircle(center: Offset.zero, radius: r); + final corners = <(Offset, double)>[ + (Offset.zero, math.pi), // 왼쪽 위 + (Offset(w, 0), -math.pi / 2), // 오른쪽 위 + (Offset(w, h), 0), // 오른쪽 아래 + (Offset(0, h), math.pi / 2), // 왼쪽 아래 + ]; + for (final (corner, start) in corners) { + // 모서리를 원점으로 옮겨 같은 모양(가로·세로 직선 + 사분원)을 돌려 그린다 + canvas + ..save() + ..translate(corner.dx, corner.dy) + ..rotate(start - math.pi); + final path = Path() + ..moveTo(0, reach) + ..lineTo(0, r) + ..arcTo(rect.shift(const Offset(r, r)), math.pi, math.pi / 2, false) + ..lineTo(reach, 0); + canvas + ..drawPath(path, paint) + ..restore(); + } + } + + @override + bool shouldRepaint(_Corners oldDelegate) => oldDelegate.color != color; +} + +/// 안내 문구 + "명함 코드 입력하기" +class _Bottom extends StatelessWidget { + const _Bottom({required this.found, required this.camera, required this.onManual}); + + final bool found; + + /// 카메라 상태 — 쓸 수 없으면 안내 문구를 바꾼다 (테스트 카메라면 null) + final MobileScannerController? camera; + final VoidCallback onManual; + + @override + Widget build(BuildContext context) { + final camera = this.camera; + final hint = camera == null + ? _hint(found: found, unavailable: false) + : ValueListenableBuilder( + valueListenable: camera, + builder: (context, state, _) => _hint(found: found, unavailable: state.error != null), + ); + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, math.max(MediaQuery.paddingOf(context).bottom + 96, 150)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + hint, + // 찾은 뒤에는 버튼을 숨기되 자리는 지켜 프레임이 움직이지 않게 한다 + Visibility.maintain( + visible: !found, + child: Padding( + padding: const EdgeInsets.only(top: 22), + child: Pressable( + pressedScale: .96, + onTap: onManual, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + decoration: BoxDecoration( + color: const Color(0x33FF7500), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0x9EFF963C), width: 1.5), + boxShadow: const [BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 4), blurRadius: 18)], + ), + child: const Text( + '명함 코드 입력하기', + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 15.5, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _hint({required bool found, required bool unavailable}) { + final text = found + ? '명함을 찾았어요! 정보 불러오는 중…' + : unavailable + ? '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.' + : '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'; + return Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13.5, height: 1.55, color: Color(0xD1FFFFFF)), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart index 3c224ba..ba7e276 100644 --- a/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart @@ -9,6 +9,7 @@ import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/core/theme/app_typography.dart'; import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart'; +import 'package:tma_app/features/pet_card/domain/card_view_mode.dart'; import 'package:tma_app/features/pet_card/domain/pet_card.dart'; import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart'; import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart'; @@ -23,16 +24,6 @@ import 'package:tma_app/shared/widgets/rup_toast.dart'; import 'package:tma_app/shared/widgets/seg_toggle.dart'; import 'package:tma_app/shared/widgets/sub_page_header.dart'; -/// 명함 보기 방식 — 간략히(명함만) / 상세히(건강 정보까지) -enum CardViewMode { - simple('간략히'), - detail('상세히'); - - const CardViewMode(this.label); - - final String label; -} - /// 펫 명함 상세 — 보기 방식 전환, 공유, 꾸미기, 삭제, 대표 명함 지정 class PetCardDetailPage extends ConsumerStatefulWidget { const PetCardDetailPage({super.key, required this.cardId}); @@ -47,15 +38,19 @@ class _PetCardDetailPageState extends ConsumerState { CardViewMode _mode = CardViewMode.detail; final _capture = CaptureController(); - Future _share(String petName) { + /// 지금 보이는 모양(간략히·상세히)이 그대로 공유된다 — 받는 사람도 이 모양으로 본다 + Future _share(String petName) async { final card = ref.read(petCardsProvider).value?.cards.where((c) => c.id == widget.cardId).firstOrNull; + final mode = _mode; + if (!await succeeded(ref.read(petCardsProvider.notifier).setShareMode(widget.cardId, mode))) return; + if (!mounted) return; return showPetCardShareSheet( context, petName: petName, code: card == null ? petCardCode(widget.cardId) : displayCardCode(card), capture: _capture.capturePng, who: TextSpan(children: [ - TextSpan(text: '${_mode.label} 모드', style: const TextStyle(color: AppColors.primary600)), + TextSpan(text: '${mode.label} 모드', style: const TextStyle(color: AppColors.primary600)), const TextSpan(text: ' 펫 명함으로 공유돼요'), ]), ); diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart index a4110d4..7c353fb 100644 --- a/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart @@ -118,17 +118,17 @@ class _PetCardManagePageState extends ConsumerState { } } -class _ManagedCard extends StatefulWidget { +class _ManagedCard extends ConsumerStatefulWidget { const _ManagedCard({required this.card, required this.pet}); final PetCard card; final Pet pet; @override - State<_ManagedCard> createState() => _ManagedCardState(); + ConsumerState<_ManagedCard> createState() => _ManagedCardState(); } -class _ManagedCardState extends State<_ManagedCard> { +class _ManagedCardState extends ConsumerState<_ManagedCard> { final _capture = CaptureController(); @override @@ -140,7 +140,7 @@ class _ManagedCardState extends State<_ManagedCard> { child: NameCard( pet: widget.pet, style: widget.card.style, - onShare: () => unawaited(sharePetCard(context, pet: widget.pet, card: widget.card, capture: _capture)), + onShare: () => unawaited(sharePetCard(context, ref, pet: widget.pet, card: widget.card, capture: _capture)), ), ), ); diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart index 725ae97..f0dfd97 100644 --- a/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart @@ -1,18 +1,25 @@ import 'dart:async'; +import 'dart:math' as math; 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/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/core/theme/app_typography.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart'; +import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart'; import 'package:tma_app/features/pet/presentation/widgets/pet_picker.dart'; +import 'package:tma_app/features/pet_card/domain/card_view_mode.dart'; import 'package:tma_app/features/pet_card/domain/pet_card.dart'; import 'package:tma_app/features/pet_card/domain/pet_card_facts.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/pet_card_providers.dart'; +import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/crown_mark.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/name_card_empty.dart'; @@ -20,13 +27,18 @@ import 'package:tma_app/features/pet_card/presentation/widgets/share_sheet.dart' import 'package:tma_app/shared/widgets/capture_boundary.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/pressable.dart'; -import 'package:tma_app/shared/widgets/rup_toast.dart'; import 'package:tma_app/shared/widgets/section_title.dart'; const double _kSearchSize = 38; const Duration _kSearchAnim = Duration(milliseconds: 340); const Cubic _kSearchCurve = Cubic(.4, 0, .2, 1); +/// 펼친 검색창 내용(돋보기·입력·닫기)이 넘치지 않는 최소 폭 — 펼쳐지는 동안은 이 폭으로 그리고 잘라 낸다 +const double _kSearchOpenMinWidth = 140; + +/// 받은 명함 줄 끝 화살표 (시안 #CFC6B9) +const Color _kChevron = Color(0xFFCFC6B9); + /// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 const double _kBottomGap = 30; @@ -127,7 +139,7 @@ class _RepresentativeCard extends ConsumerWidget { child: NameCard( pet: pet, style: card.style, - onShare: () => unawaited(sharePetCard(context, pet: pet, card: card, capture: capture)), + onShare: () => unawaited(sharePetCard(context, ref, pet: pet, card: card, capture: capture)), ), ), ], @@ -136,13 +148,17 @@ class _RepresentativeCard extends ConsumerWidget { } } -/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다 +/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다. +/// 목록에 보이는 간략히 명함이 그대로 공유된다 (받는 사람도 간략히로 본다) Future sharePetCard( - BuildContext context, { + BuildContext context, + WidgetRef ref, { required Pet pet, required PetCard card, required CaptureController capture, -}) { +}) async { + if (!await succeeded(ref.read(petCardsProvider.notifier).setShareMode(card.id, CardViewMode.simple))) return; + if (!context.mounted) return; return showPetCardShareSheet( context, petName: pet.name, @@ -155,7 +171,7 @@ Future sharePetCard( ); } -/// 보관된(받은) 명함 — 받기 기능은 서버 연동 후 연다 +/// 보관된(받은) 명함 — 명함 받기 · 검색 · 목록 class _ReceivedSection extends ConsumerStatefulWidget { const _ReceivedSection(); @@ -168,6 +184,13 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> { final _query = TextEditingController(); final _focus = FocusNode(); + @override + void initState() { + super.initState(); + // 검색어가 바뀌면 목록을 다시 거른다 + _query.addListener(() => setState(() {})); + } + @override void dispose() { _query.dispose(); @@ -191,8 +214,9 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - // 받은 명함 저장소는 명함 받기(서버 조회)와 함께 만든다 — 지금은 항상 비어 있다 - const receivedCount = 0; + final received = ref.watch(receivedCardsProvider).value ?? const []; + final query = _query.text.trim(); + final shown = received.where((c) => c.matches(query)).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -212,7 +236,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> { duration: const Duration(milliseconds: 180), opacity: _searchOpen ? 0 : 1, child: Text( - '보관된 명함 $receivedCount', + '보관된 명함 ${received.length}', style: textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.muted), ), ), @@ -237,7 +261,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> { const SizedBox(width: 5), Pressable( pressedScale: .96, - onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.qrcode), + onTap: () => unawaited(startReceivingCard(context, ref)), child: Container( padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm), decoration: BoxDecoration( @@ -260,20 +284,78 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> { ), // 시안: 제목 줄 아래 12 + 목록 위 2 const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.fromLTRB(28, 64, 28, 48), - // 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구 - child: Text( - '받은 명함이 없어요.', - textAlign: TextAlign.center, - style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted), - ), - ), + if (received.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(28, 64, 28, 48), + // 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구 + child: Text( + '받은 명함이 없어요.', + textAlign: TextAlign.center, + style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted), + ), + ) + else if (shown.isEmpty) + EmptyMessage("'$query' 검색 결과가 없어요.", padding: 28) + else + for (final card in shown) + _ReceivedRow(card: card, onTap: () => unawaited(context.push(AppRoutes.receivedCard(card.id)))), + const SizedBox(height: 6), ], ); } } +/// 받은 명함 한 줄 — 아이 사진 · 이름 · 보낸 사람 (시안 .rup-received__row) +class _ReceivedRow extends StatelessWidget { + const _ReceivedRow({required this.card, required this.onTap}); + + final ReceivedCard card; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final sub = textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted); + return Pressable( + pressedScale: 1, + pressedOpacity: .7, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 13), + child: Row( + children: [ + PetAvatar(pet: card.pet, size: 46), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(card.pet.name, style: textTheme.bodyLarge!.copyWith(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text.rich( + TextSpan(children: [ + TextSpan( + text: card.senderName, + style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink2), + ), + const TextSpan(text: '님이 보냈어요.'), + ]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: sub, + ), + ], + ), + ), + const SizedBox(width: 13), + const RupIcon(RupIconName.chevR, size: 19, color: _kChevron), + ], + ), + ), + ); + } +} + /// 오른쪽 돋보기 버튼에서 왼쪽으로 펼쳐지는 검색창 (시안 .rup-searchx) class _SearchBox extends StatelessWidget { const _SearchBox({ @@ -312,42 +394,51 @@ class _SearchBox extends StatelessWidget { boxShadow: open ? [BoxShadow(color: AppColors.primary.withValues(alpha: .14), spreadRadius: 3)] : null, ), clipBehavior: Clip.hardEdge, - child: Row( - mainAxisAlignment: open ? MainAxisAlignment.start : MainAxisAlignment.center, - children: [ - const RupIcon(RupIconName.search, size: 20, color: AppColors.ink2), - if (open) ...[ - const SizedBox(width: AppSpacing.sm), - Expanded( - child: TextField( - controller: controller, - focusNode: focusNode, - style: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, height: 1.2), - decoration: InputDecoration.collapsed( - hintText: '명함 검색', - hintStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, color: AppColors.muted), - ), - ), - ), - Semantics( - button: true, - label: '검색 닫기', - child: GestureDetector( - onTap: onClose, - child: Container( - width: 26, - height: 26, - decoration: BoxDecoration(color: AppColors.surface2, borderRadius: BorderRadius.circular(AppSpacing.sm)), - alignment: Alignment.center, - child: const RupIcon(RupIconName.x, size: 17, stroke: 2.2, color: AppColors.ink2), - ), - ), - ), - ], - ], + child: LayoutBuilder( + builder: (context, constraints) { + final width = math.max(constraints.maxWidth, open ? _kSearchOpenMinWidth : 0.0); + return OverflowBox(alignment: Alignment.centerLeft, minWidth: width, maxWidth: width, child: _row(context)); + }, ), ), ), ); } + + Widget _row(BuildContext context) { + return Row( + mainAxisAlignment: open ? MainAxisAlignment.start : MainAxisAlignment.center, + children: [ + const RupIcon(RupIconName.search, size: 20, color: AppColors.ink2), + if (open) ...[ + const SizedBox(width: AppSpacing.sm), + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, height: 1.2), + decoration: InputDecoration.collapsed( + hintText: '명함 검색', + hintStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, color: AppColors.muted), + ), + ), + ), + Semantics( + button: true, + label: '검색 닫기', + child: GestureDetector( + onTap: onClose, + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration(color: AppColors.surface2, borderRadius: BorderRadius.circular(AppSpacing.sm)), + alignment: Alignment.center, + child: const RupIcon(RupIconName.x, size: 17, stroke: 2.2, color: AppColors.ink2), + ), + ), + ), + ], + ], + ); + } } 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 new file mode 100644 index 0000000..f6d639f --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart @@ -0,0 +1,219 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/core/theme/app_typography.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/providers/received_card_providers.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/card_code_sheet.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +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'; + +/// 명함 받기 — 스캐너(또는 코드 입력)로 명함을 찾아 저장 확인 화면을 연다 +Future startReceivingCard(BuildContext context, WidgetRef ref) async { + if (!ref.read(receivedCardsProvider.notifier).available) { + ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode); + return; + } + final outcome = await context.push(AppRoutes.petCardScan); + if (!context.mounted) return; + final card = switch (outcome) { + ScanFound(:final card) => card, + ScanTypeCode() => await showCardCodeSheet(context), + null => null, + }; + if (card == null || !context.mounted) return; + await context.push(AppRoutes.petCardReceive, extra: card); +} + +/// 받은 명함 저장 확인 — 명함을 보고 메모를 남긴 뒤 보관함에 저장한다 +class ReceiveCardPage extends ConsumerStatefulWidget { + const ReceiveCardPage({super.key, required this.card, this.today}); + + final SharedCard card; + + /// 받은 날짜 기준 (테스트용). 없으면 오늘 + final DateTime? today; + + @override + ConsumerState createState() => _ReceiveCardPageState(); +} + +class _ReceiveCardPageState extends ConsumerState { + final _memo = TextEditingController(); + bool _saving = false; + + @override + void initState() { + super.initState(); + // 글자 수 표시를 갱신한다 + _memo.addListener(() => setState(() {})); + } + + @override + void dispose() { + _memo.dispose(); + super.dispose(); + } + + Future _save() async { + if (_saving) return; + setState(() => _saving = true); + final note = _memo.text.trim(); + final name = widget.card.pet.name; + final saved = await succeeded( + ref.read(receivedCardsProvider.notifier).receive(widget.card.code, note: note.isEmpty ? null : note), + ); + if (!mounted) return; + if (!saved) { + setState(() => _saving = false); + return; + } + context.pop(); + ref.read(toastProvider.notifier).show('$name 명함을 보관함에 저장했어요'); + } + + @override + Widget build(BuildContext context) { + final card = widget.card; + final textTheme = Theme.of(context).textTheme; + return Scaffold( + body: SafeArea( + bottom: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xs, + AppSpacing.xl, + MediaQuery.paddingOf(context).bottom + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header(title: '${card.pet.name}의 명함', onBack: () => context.pop()), + NameCard(pet: card.pet, style: card.style, today: widget.today), + const DividerBand(top: 30), + ReceivedCardInfo(card: card, receivedAt: widget.today ?? DateTime.now()), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.only(left: 2, bottom: AppSpacing.sm), + child: Row( + children: [ + const RupIcon(RupIconName.edit, size: 15, stroke: 2.2, color: AppColors.ink), + const SizedBox(width: 6), + Text('내 메모', style: textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w700)), + ], + ), + ), + _MemoField(controller: _memo), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Text( + '${_memo.text.characters.length}/$kReceivedNoteMaxLength', + textAlign: TextAlign.right, + style: textTheme.bodySmall!.copyWith(fontSize: 11.5, color: AppColors.muted), + ), + ), + const SizedBox(height: AppSpacing.sm), + RupButton(label: '보관함에 저장', dimmed: _saving, onPressed: () => unawaited(_save())), + ], + ), + ), + ), + ); + } +} + +/// 가운데 제목 머리 — 아래 구분선 (시안 저장 확인 화면) +class _Header extends StatelessWidget { + const _Header({required this.title, required this.onBack}); + + final String title; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 18), + padding: const EdgeInsets.only(top: 2, bottom: AppSpacing.lg), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.line2))), + child: Row( + children: [ + Semantics( + button: true, + label: '뒤로', + child: GestureDetector( + onTap: onBack, + behavior: HitTestBehavior.opaque, + child: const Padding( + padding: EdgeInsets.all(AppSpacing.xs), + child: RupIcon(RupIconName.chevL, color: AppColors.ink), + ), + ), + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 17, + fontWeight: FontWeight.w600, + height: 1.2, + color: AppColors.ink, + ), + ), + ), + const SizedBox(width: 30), + ], + ), + ); + } +} + +/// 받은 명함 메모칸 (시안 textarea — 바탕 surface-2, 모서리 14, 최소 높이 92) +class _MemoField extends StatelessWidget { + const _MemoField({required this.controller}); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14, height: 1.55, color: AppColors.ink); + return Container( + constraints: const BoxConstraints(minHeight: 92), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + decoration: BoxDecoration( + color: AppColors.surface2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.line), + ), + child: TextField( + controller: controller, + keyboardType: TextInputType.multiline, + minLines: 3, + maxLines: null, + inputFormatters: [LengthLimitingTextInputFormatter(kReceivedNoteMaxLength)], + style: style, + cursorColor: AppColors.primary, + decoration: InputDecoration.collapsed( + hintText: '이 명함에 대해 기억할 내용을 적어두세요. (어디서 만났는지, 특징 등)', + hintStyle: style.copyWith(color: AppColors.muted), + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/pages/received_card_detail_page.dart b/flutter/lib/features/pet_card/presentation/pages/received_card_detail_page.dart new file mode 100644 index 0000000..8d7d682 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/received_card_detail_page.dart @@ -0,0 +1,114 @@ +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/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.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/providers/received_card_providers.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; +import 'package:tma_app/shared/widgets/section_title.dart'; +import 'package:tma_app/shared/widgets/sub_page_header.dart'; + +/// 보관함의 받은 명함 — 보낸 사람이 공유한 모양 그대로 보여 준다 +class ReceivedCardDetailPage extends ConsumerWidget { + const ReceivedCardDetailPage({super.key, required this.receivedId}); + + final String receivedId; + + Future _delete(BuildContext context, WidgetRef ref, ReceivedCard card) async { + // 화면을 닫은 뒤에도 쓰도록 먼저 꺼내 둔다 + final cards = ref.read(receivedCardsProvider.notifier); + final toast = ref.read(toastProvider.notifier); + // 먼저 화면을 닫아 지워진 명함을 그리지 않게 한다 (시안: 확인 없이 바로 삭제) + context.pop(); + if (!await succeeded(cards.delete(card.id))) return; + toast.show('보관함에서 삭제했어요'); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final card = ref.watch(receivedCardByIdProvider(receivedId)); + if (card == null) return const Scaffold(body: SizedBox.shrink()); + final detail = card.mode == CardViewMode.detail; + + return Scaffold( + body: SafeArea( + bottom: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xs, + AppSpacing.xl, + MediaQuery.paddingOf(context).bottom + 40, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SubPageHeader( + title: card.pet.name, + subtitle: '받은 명함 · ${card.senderName}님', + displayFont: true, + bottomGap: 16, + onBack: () => context.pop(), + ), + if (detail) + PetIdCard(pet: card.pet, style: card.style) + else + Padding( + padding: const EdgeInsets.only(top: 18), + child: NameCard(pet: card.pet, style: card.style), + ), + DividerBand(top: detail ? 52 : 30), + ReceivedCardInfo(card: card, receivedAt: card.receivedAt, showNote: true, note: card.note), + const SizedBox(height: 52), + _DeleteButton(onTap: () => unawaited(_delete(context, ref, card))), + ], + ), + ), + ), + ); + } +} + +class _DeleteButton extends StatelessWidget { + const _DeleteButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const RupIcon(RupIconName.x, size: 16, stroke: 2.2, color: AppColors.catHospital), + const SizedBox(width: 6), + Text( + '보관함에서 삭제', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.catHospital, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart b/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart index 4eae291..88e0300 100644 --- a/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart +++ b/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart @@ -4,6 +4,7 @@ import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet/presentation/providers/pet_providers.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'; /// 내가 만든 명함 + 아이별 대표 명함 @@ -66,6 +67,9 @@ class PetCardNotifier extends AsyncNotifier { )); } + /// 공유 시트를 열 때 — 받는 사람이 볼 모양을 서버에 알린다 + Future setShareMode(String cardId, CardViewMode mode) => _repo.setShareMode(cardId, mode); + Future setRepresentative(PetCard card) async { await _repo.setRepresentative(card.petId, card.id); _emit(_current.copyWith(representatives: {..._current.representatives, card.petId: card.id})); diff --git a/flutter/lib/features/pet_card/presentation/providers/received_card_providers.dart b/flutter/lib/features/pet_card/presentation/providers/received_card_providers.dart new file mode 100644 index 0000000..b1e2ec5 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/providers/received_card_providers.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/features/pet_card/data/received_card_repository.dart'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; + +/// 보관함의 받은 명함 — 최근에 받은 명함이 앞 +class ReceivedCardNotifier extends AsyncNotifier> { + ReceivedCardRepository get _repo => ref.read(receivedCardRepositoryProvider); + + @override + Future> build() async { + // 회원이 바뀌면(로그인·로그아웃) 다시 읽는다 + ref.watch(sessionEpochProvider); + return ref.watch(receivedCardRepositoryProvider).fetch(); + } + + List get _current => state.value ?? const []; + + void _emit(List next) { + if (ref.mounted) state = AsyncData(next); + } + + /// 명함 받기를 쓸 수 있는지 — 기기 저장 모드에서는 받을 명함이 없다 + bool get available => _repo.available; + + /// 받기 전 미리보기 (명함 코드로 조회) + Future preview(String code) => _repo.preview(code); + + Future receive(String code, {String? note}) async { + final card = await _repo.receive(code, note: note); + _emit([card, ..._current]); + return card; + } + + Future delete(String id) async { + await _repo.delete(id); + _emit(_current.where((c) => c.id != id).toList()); + } +} + +final receivedCardsProvider = AsyncNotifierProvider>( + ReceivedCardNotifier.new, +); + +final receivedCardByIdProvider = Provider.family((ref, id) { + return ref.watch(receivedCardsProvider).value?.where((c) => c.id == id).firstOrNull; +}); diff --git a/flutter/lib/features/pet_card/presentation/widgets/card_code_sheet.dart b/flutter/lib/features/pet_card/presentation/widgets/card_code_sheet.dart new file mode 100644 index 0000000..593c509 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/card_code_sheet.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/theme/app_typography.dart'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; +import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/rup_form_fields.dart'; +import 'package:tma_app/shared/widgets/rup_sheet.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; + +/// 명함 코드 직접 입력 — 찾은 명함을 돌려준다 (못 찾았거나 닫으면 null) +Future showCardCodeSheet(BuildContext context) { + return showRupSheet( + context, + title: '명함코드 입력하기', + flushBottom: true, + builder: (context) => const _CodeBody(), + ); +} + +class _CodeBody extends ConsumerStatefulWidget { + const _CodeBody(); + + @override + ConsumerState<_CodeBody> createState() => _CodeBodyState(); +} + +class _CodeBodyState extends ConsumerState<_CodeBody> { + final _code = TextEditingController(); + bool _busy = false; + + @override + void initState() { + super.initState(); + // 입력이 생기면 버튼을 켠다 + _code.addListener(() => setState(() {})); + } + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy || _code.text.trim().isEmpty) return; + final code = parseCardCode(_code.text); + if (code == null) { + // 명함 코드 모양이 아니다 — 서버가 못 찾았을 때와 같은 안내 + ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert); + return; + } + setState(() => _busy = true); + try { + final card = await ref.read(receivedCardsProvider.notifier).preview(code); + if (mounted) Navigator.of(context).pop(card); + } catch (e) { + if (!isReportedApiFailure(e)) rethrow; + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final ready = _code.text.trim().isNotEmpty && !_busy; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Text( + '아이 명함의 코드를 입력해주세요.\n명함 코드는 내 아이 명함 공유하기 버튼을 누르면 확인할 수 있어요.', + textAlign: TextAlign.center, + style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5), + ), + ), + RupField( + label: '명함 코드', + child: RupTextField( + controller: _code, + hintText: '예: 0428-117', + autofocus: true, + // 하이픈 없이 7자리만 넣어도 받는다 (parseCardCode) + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + textAlign: TextAlign.center, + textStyle: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 18, + letterSpacing: 1, + height: AppTypography.displayNormalHeight, + ), + onSubmitted: (_) => unawaited(_submit()), + ), + ), + Opacity( + opacity: ready ? 1 : .45, + child: RupSheetBleedButton(label: '보관함에 저장', onPressed: () => unawaited(_submit())), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/received_card_info.dart b/flutter/lib/features/pet_card/presentation/widgets/received_card_info.dart new file mode 100644 index 0000000..29f4675 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/received_card_info.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/theme/app_typography.dart'; +import 'package:tma_app/core/utils/date_format.dart'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/photo_image.dart'; + +// 시안 InfoRow 아이콘 색 +const Color _kDateTint = Color(0xFFC084FC); +const Color _kNoteTint = Color(0xFF5BA8D8); +const double _kIconBox = 36; + +/// 받은 명함의 "명함 정보" — 보낸 사람 · 받은 날짜 (+ 내 메모) +class ReceivedCardInfo extends StatelessWidget { + const ReceivedCardInfo({ + super.key, + required this.card, + required this.receivedAt, + this.showNote = false, + this.note, + }); + + final SharedCard card; + final DateTime receivedAt; + + /// 보관함의 받은 명함이면 내 메모 줄도 보여 준다 (없으면 "남긴 메모가 없어요.") + final bool showNote; + final String? note; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final value = textTheme.bodyMedium!.copyWith(fontSize: 14.5, fontWeight: FontWeight.w700, color: AppColors.ink); + final memo = note; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 6), + child: Text('명함 정보', style: textTheme.titleSmall!.copyWith(fontSize: 15, fontWeight: FontWeight.w700)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _InfoRow( + first: true, + tint: AppColors.primary, + icon: _SenderMark(card: card), + label: '보낸 사람', + child: Text.rich( + TextSpan(children: [ + TextSpan(text: card.senderName), + const TextSpan(text: '님', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.ink2)), + ]), + style: value, + ), + ), + _InfoRow( + tint: _kDateTint, + icon: const RupIcon(RupIconName.calendar, size: 18, stroke: 2, color: _kDateTint), + label: '받은 날짜', + child: Text(formatYmd(receivedAt), style: value), + ), + if (showNote) + _InfoRow( + tint: _kNoteTint, + icon: const RupIcon(RupIconName.text, size: 18, stroke: 2, color: _kNoteTint), + label: '내 메모', + child: Text( + memo ?? '남긴 메모가 없어요.', + style: textTheme.bodyMedium!.copyWith( + fontSize: 13.5, + height: 1.6, + fontWeight: FontWeight.w500, + color: memo == null ? AppColors.muted : AppColors.ink2, + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +/// 시안 InfoRow — 색 상자 아이콘 + 라벨 + 값 +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.tint, + required this.icon, + required this.label, + required this.child, + this.first = false, + }); + + final Color tint; + final Widget icon; + final String label; + final Widget child; + final bool first; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 13), + decoration: BoxDecoration( + border: first ? null : const Border(top: BorderSide(color: AppColors.line2)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: _kIconBox, + height: _kIconBox, + decoration: BoxDecoration( + // 시안 color-mix(tint 14%, transparent) + color: tint.withValues(alpha: .14), + borderRadius: BorderRadius.circular(11), + ), + clipBehavior: Clip.antiAlias, + alignment: Alignment.center, + child: icon, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 12, color: AppColors.muted), + ), + const SizedBox(height: 3), + child, + ], + ), + ), + ], + ), + ); + } +} + +/// 보낸 사람 — 프로필 사진이 있으면 사진, 없으면 닉네임 첫 글자 (시안 SenderAvatar) +class _SenderMark extends StatelessWidget { + const _SenderMark({required this.card}); + + final SharedCard card; + + @override + Widget build(BuildContext context) { + final initial = Text( + card.senderName.characters.take(1).toString(), + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 15, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: AppColors.primary, + ), + ); + final photo = card.senderPhotoPath; + if (photo == null) return initial; + return SizedBox.expand(child: PhotoImage(path: photo, fallback: Center(child: initial))); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart index 6dd2c75..ee1bf19 100644 --- a/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart +++ b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart @@ -8,6 +8,7 @@ import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/storage/album_saver.dart'; import 'package:tma_app/core/theme/app_shadows.dart'; +import 'package:tma_app/features/pet_card/domain/received_card.dart'; import 'package:tma_app/shared/widgets/blurred_modal.dart'; import 'package:tma_app/shared/widgets/capture_boundary.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; @@ -16,9 +17,6 @@ import 'package:tma_app/shared/widgets/rup_button.dart'; import 'package:tma_app/shared/widgets/rup_sheet.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; -/// QR 에 담는 주소 — 앱 딥링크. 서버가 명함 조회 API 를 열면 같은 코드로 찾는다 -String petCardDeepLink(String code) => 'rup://pet-card/$code'; - const double _kQrSize = 220; const double _kQrBadge = 54; diff --git a/flutter/lib/shared/widgets/photo_image.dart b/flutter/lib/shared/widgets/photo_image.dart index fb02fa3..3210884 100644 --- a/flutter/lib/shared/widgets/photo_image.dart +++ b/flutter/lib/shared/widgets/photo_image.dart @@ -7,16 +7,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tma_app/core/api/dio_client.dart'; import 'package:tma_app/core/api/dio_provider.dart'; -import 'package:tma_app/core/api/file_uploader.dart'; import 'package:tma_app/core/api/server_file.dart'; -/// 서버에 올린 사진 — 앱의 HTTP 클라이언트(Dio)로 받아 토큰 첨부·갱신을 그대로 쓴다. +/// 서버의 사진 — 앱의 HTTP 클라이언트(Dio)로 받아 토큰 첨부·갱신을 그대로 쓴다. /// (Image.network 에 토큰을 직접 붙이면 액세스 토큰이 만료된 뒤 사진만 깨진다) @immutable class ServerFileImage extends ImageProvider { - const ServerFileImage(this.fileId, this.dio); + const ServerFileImage(this.apiPath, this.dio); - final String fileId; + /// baseUrl 아래 경로 — `/files/` 또는 `/shared-pet-cards//images/pet` 등 + final String apiPath; final Dio dio; @override @@ -29,7 +29,7 @@ class ServerFileImage extends ImageProvider { Future _load(ImageDecoderCallback decode) async { final response = await dio.get>( - '$kFilesPath/$fileId', + apiPath, // 사진 하나가 안 보여도 알림을 띄우지 않는다 — 화면은 빈칸(기본 그림)으로 둔다 options: Options(responseType: ResponseType.bytes, extra: {kSilentExtra: true}), ); @@ -37,12 +37,12 @@ class ServerFileImage extends ImageProvider { return decode(buffer); } - /// 파일 id 가 같으면 같은 사진이다 — 서버 파일은 id 별로 내용이 바뀌지 않는다 + /// 경로가 같으면 같은 사진이다 — 서버 파일은 id 별로 내용이 바뀌지 않는다 @override - bool operator ==(Object other) => other is ServerFileImage && other.fileId == fileId; + bool operator ==(Object other) => other is ServerFileImage && other.apiPath == apiPath; @override - int get hashCode => fileId.hashCode; + int get hashCode => apiPath.hashCode; } /// 사진 한 장 — 서버 파일 · 기기 파일 · 웹 미리보기(브라우저 임시 주소)를 같은 자리에 그린다. @@ -63,9 +63,9 @@ class PhotoImage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { Widget error(BuildContext context, Object e, StackTrace? s) => fallback; - final fileId = serverFileIdOf(path); - if (fileId != null) { - return Image(image: ServerFileImage(fileId, ref.watch(dioProvider)), fit: fit, errorBuilder: error); + final apiPath = serverPathOf(path); + if (apiPath != null) { + return Image(image: ServerFileImage(apiPath, ref.watch(dioProvider)), fit: fit, errorBuilder: error); } if (kIsWeb) return Image.network(path, fit: fit, errorBuilder: error); return Image.file(File(path), fit: fit, errorBuilder: error); diff --git a/flutter/lib/shared/widgets/pressable.dart b/flutter/lib/shared/widgets/pressable.dart index 5b35d0f..1dc856e 100644 --- a/flutter/lib/shared/widgets/pressable.dart +++ b/flutter/lib/shared/widgets/pressable.dart @@ -10,6 +10,7 @@ class Pressable extends StatefulWidget { required this.onTap, this.pressedScale = 0.95, this.pressedBrightness = 1, + this.pressedOpacity = 1, this.semanticLabel, this.behavior = HitTestBehavior.opaque, }); @@ -21,6 +22,9 @@ class Pressable extends StatefulWidget { /// 누르는 동안 밝기 — 시안 filter: brightness(.94) 등 (1 이면 그대로) final double pressedBrightness; + /// 누르는 동안 불투명도 — 시안 opacity: .7 (받은 명함 줄 등) + final double pressedOpacity; + /// 스크린리더용 이름. 없으면 자식의 텍스트를 그대로 읽는다. final String? semanticLabel; final HitTestBehavior behavior; @@ -59,9 +63,13 @@ class _PressableState extends State { scale: _pressed ? widget.pressedScale : 1, duration: AppDurations.press, curve: Curves.easeOut, - child: _pressed && widget.pressedBrightness != 1 - ? ColorFiltered(colorFilter: _brightness(widget.pressedBrightness), child: widget.child) - : widget.child, + child: AnimatedOpacity( + opacity: _pressed ? widget.pressedOpacity : 1, + duration: AppDurations.press, + child: _pressed && widget.pressedBrightness != 1 + ? ColorFiltered(colorFilter: _brightness(widget.pressedBrightness), child: widget.child) + : widget.child, + ), ), ), ), diff --git a/flutter/lib/shared/widgets/rup_form_fields.dart b/flutter/lib/shared/widgets/rup_form_fields.dart index 2fff738..9c4f4f3 100644 --- a/flutter/lib/shared/widgets/rup_form_fields.dart +++ b/flutter/lib/shared/widgets/rup_form_fields.dart @@ -124,6 +124,8 @@ class RupTextField extends StatefulWidget { this.prefix, this.suffix, this.focusNode, + this.textAlign = TextAlign.start, + this.textStyle, }); final TextEditingController controller; @@ -147,6 +149,10 @@ class RupTextField extends StatefulWidget { final Widget? suffix; final FocusNode? focusNode; + /// 글자 정렬·모양 — 명함 코드처럼 가운데 큰 글씨로 받는 칸 + final TextAlign textAlign; + final TextStyle? textStyle; + @override State createState() => _RupTextFieldState(); } @@ -174,7 +180,7 @@ class _RupTextFieldState extends State { @override Widget build(BuildContext context) { final base = _inputText(context); - final style = widget.multiline ? base.copyWith(height: _kTextareaLineHeight) : base; + final style = (widget.multiline ? base.copyWith(height: _kTextareaLineHeight) : base).merge(widget.textStyle); final field = TextField( controller: widget.controller, focusNode: _focus, @@ -190,6 +196,7 @@ class _RupTextFieldState extends State { minLines: widget.multiline ? 3 : 1, maxLines: widget.multiline ? null : 1, style: style, + textAlign: widget.textAlign, cursorColor: AppColors.primary, decoration: InputDecoration.collapsed( hintText: widget.hintText, diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index e1b46d3..0e3c427 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import file_selector_macos import flutter_secure_storage_darwin import gal +import mobile_scanner import share_plus import shared_preferences_foundation @@ -15,6 +16,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 1af1e1c..2a33d1f 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -536,6 +536,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0" + url: "https://pub.dev" + source: hosted + version: "7.4.2" native_toolchain_c: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 19009c7..a5d1ba7 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: share_plus: ^13.3.0 qr_flutter: ^4.1.0 gal: ^2.3.3 + mobile_scanner: ^7.4.2 dev_dependencies: flutter_test: diff --git a/flutter/test/api_received_card_repository_test.dart b/flutter/test/api_received_card_repository_test.dart new file mode 100644 index 0000000..edd1386 --- /dev/null +++ b/flutter/test/api_received_card_repository_test.dart @@ -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 _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': [], + 'conditions': [], + 'watchConditions': ['허리'], + }, + }; + +const Map _styleJson = { + 'tint': '#C9794A', + 'tint2': '#E2A878', + 'nameBg': null, + 'nameBgAsset': null, + 'textLight': false, + 'scrim': false, + 'scrimTone': 'light', +}; + +Map _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)); + }); +} diff --git a/flutter/test/receive_card_flow_test.dart b/flutter/test/receive_card_flow_test.dart new file mode 100644 index 0000000..82de244 --- /dev/null +++ b/flutter/test/receive_card_flow_test.dart @@ -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 cards = const [], this.failure}) : cards = [...cards]; + + final List cards; + + /// 미리보기를 이 코드로 실패시킨다 (인터셉터가 알린 실패처럼) + final String? failure; + final previewed = []; + String? savedNote; + + @override + bool get available => true; + + @override + Future> fetch() async => [...cards]; + + @override + Future preview(String code) async { + previewed.add(code); + if (failure case final code?) throw ApiException.fromCode(code); + return _shared(); + } + + @override + Future receive(String code, {String? note}) async { + savedNote = note; + final card = _received('new', note: note); + cards.insert(0, card); + return card; + } + + @override + Future delete(String id) async => cards.removeWhere((c) => c.id == id); +} + +/// 실제 카메라 대신 — 누르면 QR 을 읽은 것처럼 알린다 +Widget _fakeCamera(ValueChanged 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 _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 _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); + }); +}