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 new file mode 100644 index 0000000..e24c24f --- /dev/null +++ b/flutter/lib/features/pet_card/data/api_pet_card_repository.dart @@ -0,0 +1,106 @@ +import 'package:dio/dio.dart'; +import 'package:tma_app/core/api/api_paging.dart'; +import 'package:tma_app/core/api/file_uploader.dart'; +import 'package:tma_app/core/api/server_file.dart'; +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/pet_card.dart'; + +/// 펫 명함 경로 (backend PetCardsController · RepresentativeCardController) +const String _kCardsPath = '/pet-cards'; +String _representativePath(String petId) => '/pets/$petId/representative-card'; + +/// 서버의 꾸미기 값 → 앱의 CardStyle (배경 그림은 앱 그림 경로 또는 서버 파일) +CardStyle cardStyleFromApi(Map json) { + 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?) ?? serverFileRefOrNull(json['nameBgFileId'] as String?), + textLight: json['textLight'] as bool? ?? false, + scrim: json['scrim'] as bool? ?? false, + scrimTone: ScrimTone.values.asNameMap()[json['scrimTone']] ?? ScrimTone.light, + ); +} + +PetCard petCardFromApi(Map json) => PetCard( + id: json['id']! as String, + petId: json['petId']! as String, + code: json['code'] as String?, + style: cardStyleFromApi(json['style']! as Map), + ); + +/// 서버 저장소 — 배경 사진(기기 사진)은 저장할 때 올리고, 앱 그림은 경로만 보낸다 +class ApiPetCardRepository implements PetCardRepository { + ApiPetCardRepository({required Dio dio, required SessionStore session, required FileUploader uploader}) + : _dio = dio, + _session = session, + _uploader = uploader; + + final Dio _dio; + final SessionStore _session; + final FileUploader _uploader; + + Future> _styleToApi(CardStyle style) async { + final image = style.nameBgImage; + final asset = image != null && isBundledAsset(image) ? image : null; + return { + 'tint': hexOf(style.tint), + 'tint2': hexOf(style.tint2), + 'nameBg': style.nameBg == null ? null : hexOf(style.nameBg!), + 'nameBgAsset': asset, + 'nameBgFileId': asset == null ? await _uploader.ensureUploaded(image) : null, + 'textLight': style.textLight, + 'scrim': style.scrim, + 'scrimTone': style.scrimTone.name, + }; + } + + @override + Future fetch() async { + if (!_session.hasSession) return (cards: const [], representatives: const {}); + final reps = {}; + final cards = await fetchAllPages(_dio, _kCardsPath, (json) { + final card = petCardFromApi(json); + if (json['isRepresentative'] == true) reps[card.petId] = card.id; + return card; + }); + return (cards: cards, representatives: reps); + } + + @override + Future create(String petId, CardStyle style) async { + final response = await _dio.post>( + _kCardsPath, + data: {'petId': petId, 'style': await _styleToApi(style)}, + ); + return petCardFromApi(response.data!); + } + + @override + Future update(String cardId, CardStyle style) async { + final response = await _dio.put>( + '$_kCardsPath/$cardId', + data: {'style': await _styleToApi(style)}, + ); + return petCardFromApi(response.data!); + } + + /// 대표 명함이었으면 서버가 대표 지정도 함께 지운다 + @override + Future delete(String cardId) => _dio.delete('$_kCardsPath/$cardId'); + + @override + Future setRepresentative(String petId, String cardId) => + _dio.put(_representativePath(petId), data: {'cardId': cardId}); + + @override + Future clearRepresentative(String petId) => _dio.delete(_representativePath(petId)); + + /// 서버가 아이를 지우면서 명함도 함께 지웠다 + @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 1c93753..583271b 100644 --- a/flutter/lib/features/pet_card/data/pet_card_repository.dart +++ b/flutter/lib/features/pet_card/data/pet_card_repository.dart @@ -2,33 +2,43 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.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/session_store.dart'; +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/pet_card.dart'; -/// 펫 명함 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다. +/// 내 명함 + 아이별 대표 명함 (petId → cardId) +typedef PetCardBook = ({List cards, Map representatives}); + +/// 펫 명함 저장소 계약 — 서버(ApiPetCardRepository) / 기기(LocalPetCardRepository) abstract interface class PetCardRepository { - Future> fetchCards(); + Future fetch(); Future create(String petId, CardStyle style); Future update(String cardId, CardStyle style); + /// 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 Future delete(String cardId); - /// 아이별 대표 명함 (petId → cardId) - Future> fetchRepresentatives(); - Future setRepresentative(String petId, String cardId); Future clearRepresentative(String petId); + + /// 아이를 지웠을 때 그 아이의 명함을 지운다. + /// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 명함도 함께 지웠다. + Future dropPet(String petId); } const String _kCardsKey = 'petCards.v1'; const String _kRepKey = 'petCards.rep.v1'; const String _kBgImagesKey = 'petCards.bgImages.v1'; -/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다. +/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트). class LocalPetCardRepository implements PetCardRepository { LocalPetCardRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now; @@ -53,8 +63,10 @@ class LocalPetCardRepository implements PetCardRepository { return (jsonDecode(raw) as Map).map((k, v) => MapEntry(k, v! as String)); } + Future _writeRep(Map reps) => _prefs.setString(_kRepKey, jsonEncode(reps)); + @override - Future> fetchCards() async => _read(); + Future fetch() async => (cards: _read(), representatives: _readRep()); @override Future create(String petId, CardStyle style) async { @@ -74,21 +86,24 @@ class LocalPetCardRepository implements PetCardRepository { @override Future delete(String cardId) async { await _write(_read().where((c) => c.id != cardId).toList()); + final reps = _readRep(); + if (reps.containsValue(cardId)) await _writeRep(reps..removeWhere((_, id) => id == cardId)); } @override - Future> fetchRepresentatives() async => _readRep(); + Future setRepresentative(String petId, String cardId) => _writeRep({..._readRep(), petId: cardId}); @override - Future setRepresentative(String petId, String cardId) => - _prefs.setString(_kRepKey, jsonEncode({..._readRep(), petId: cardId})); + Future clearRepresentative(String petId) => _writeRep(_readRep()..remove(petId)); @override - Future clearRepresentative(String petId) => - _prefs.setString(_kRepKey, jsonEncode({..._readRep()}..remove(petId))); + Future dropPet(String petId) async { + await _write(_read().where((c) => c.petId != petId).toList()); + await clearRepresentative(petId); + } } -/// 명함 배경으로 쓰려고 추가한 내 사진 (최대 [kMaxUserBackgrounds]장) — 다음에 명함을 꾸밀 때도 남는다 +/// 명함 배경으로 쓰려고 추가한 내 사진 (최대 [kMaxUserBackgrounds]장) — 다음에 명함을 꾸밀 때도 남는다 (기기에 둔다) const int kMaxUserBackgrounds = 5; class CardBackgroundRepository { @@ -102,7 +117,14 @@ class CardBackgroundRepository { } final petCardRepositoryProvider = Provider((ref) { - return LocalPetCardRepository(ref.watch(sharedPreferencesProvider)); + return switch (ref.watch(dataModeProvider)) { + DataMode.local => LocalPetCardRepository(ref.watch(sharedPreferencesProvider)), + DataMode.api => ApiPetCardRepository( + dio: ref.watch(dioProvider), + session: ref.watch(sessionStoreProvider), + uploader: ref.watch(fileUploaderProvider), + ), + }; }); final cardBackgroundRepositoryProvider = Provider((ref) { diff --git a/flutter/lib/features/pet_card/domain/pet_card.dart b/flutter/lib/features/pet_card/domain/pet_card.dart index 67193e9..6106629 100644 --- a/flutter/lib/features/pet_card/domain/pet_card.dart +++ b/flutter/lib/features/pet_card/domain/pet_card.dart @@ -5,19 +5,23 @@ const int kMaxPetCards = 10; /// 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값 class PetCard { - const PetCard({required this.id, required this.petId, required this.style}); + const PetCard({required this.id, required this.petId, required this.style, this.code}); final String id; final String petId; final CardStyle style; - PetCard copyWith({CardStyle? style}) => PetCard(id: id, petId: petId, style: style ?? this.style); + /// 서버가 발급한 명함 코드 "0000-000" — 기기 저장 모드에는 없다 (화면은 displayCardCode 로 보여 준다) + final String? code; - Map toJson() => {'id': id, 'petId': petId, 'style': style.toJson()}; + PetCard copyWith({CardStyle? style}) => PetCard(id: id, petId: petId, style: style ?? this.style, code: code); + + Map toJson() => {'id': id, 'petId': petId, 'style': style.toJson(), 'code': ?code}; factory PetCard.fromJson(Map json) => PetCard( id: json['id']! as String, petId: json['petId']! as String, style: CardStyle.fromJson((json['style'] as Map?) ?? const {}), + code: json['code'] as String?, ); } diff --git a/flutter/lib/features/pet_card/domain/pet_card_facts.dart b/flutter/lib/features/pet_card/domain/pet_card_facts.dart index e6e8e5b..5306411 100644 --- a/flutter/lib/features/pet_card/domain/pet_card_facts.dart +++ b/flutter/lib/features/pet_card/domain/pet_card_facts.dart @@ -1,5 +1,7 @@ // 명함에 적히는 값 계산 — 순수 함수 +import 'package:tma_app/features/pet_card/domain/pet_card.dart'; + /// 만 나이(년) — 이번 달 생일 날짜가 아직 안 왔으면 한 달을 덜 센다 int petAgeYears(DateTime birth, DateTime today) { var months = (today.year - birth.year) * 12 + (today.month - birth.month); @@ -21,7 +23,7 @@ String shortBirth(DateTime d) { } /// 명함 코드 "0000-000" — 같은 시드면 항상 같은 코드 (시안 petCardCode). -/// 서버가 코드를 발급하게 되면 그 값으로 바꾼다. +/// 서버가 발급한 코드가 없을 때(기기 저장 모드)만 쓴다 — [displayCardCode]. String petCardCode(String seed) { var h = 0; for (final unit in seed.codeUnits) { @@ -31,3 +33,6 @@ String petCardCode(String seed) { final b = ((h ~/ 10000) % 1000).toString().padLeft(3, '0'); return '$a-$b'; } + +/// 명함에 보여 줄 코드 — 서버가 발급한 코드, 없으면(기기 저장 모드) 명함 id 로 만든 코드 +String displayCardCode(PetCard card) => card.code ?? petCardCode(card.id); 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 9780bc9..3c224ba 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 @@ -3,12 +3,14 @@ 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/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/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'; 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'; @@ -46,10 +48,11 @@ class _PetCardDetailPageState extends ConsumerState { final _capture = CaptureController(); Future _share(String petName) { + final card = ref.read(petCardsProvider).value?.cards.where((c) => c.id == widget.cardId).firstOrNull; return showPetCardShareSheet( context, petName: petName, - codeSeed: widget.cardId, + code: card == null ? petCardCode(widget.cardId) : displayCardCode(card), capture: _capture.capturePng, who: TextSpan(children: [ TextSpan(text: '${_mode.label} 모드', style: const TextStyle(color: AppColors.primary600)), @@ -64,7 +67,7 @@ class _PetCardDetailPageState extends ConsumerState { toast.show('이미 대표 명함이에요', icon: RupIconName.star); return; } - await ref.read(petCardsProvider.notifier).setRepresentative(card); + if (!await succeeded(ref.read(petCardsProvider.notifier).setRepresentative(card))) return; toast.show('대표 명함으로 설정했어요', icon: RupIconName.star); } @@ -86,7 +89,7 @@ class _PetCardDetailPageState extends ConsumerState { if (!ok || !mounted) return; // 먼저 화면을 닫아 지워진 명함을 그리지 않게 한다 context.pop(); - await ref.read(petCardsProvider.notifier).delete(card.id); + if (!await succeeded(ref.read(petCardsProvider.notifier).delete(card.id))) return; ref.read(toastProvider.notifier).show('펫 명함을 삭제했어요'); } diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart index 84a4163..2c914c1 100644 --- a/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.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'; @@ -98,7 +99,7 @@ class _CardEditorState extends ConsumerState<_CardEditor> { final file = await _pickImage(); if (file == null) return; final path = await ref.read(petPhotoStoreProvider).persist(file); - await ref.read(petListProvider.notifier).updatePet(pet.id, pet.withPhoto(path)); + await succeeded(ref.read(petListProvider.notifier).updatePet(pet.id, pet.withPhoto(path))); } Future _addBackground() async { @@ -151,6 +152,9 @@ class _CardEditorState extends ConsumerState<_CardEditor> { // 만든 명함의 상세로 — 뒤로 가면 명함을 만들기 시작한 화면으로 돌아간다 if (mounted) context.pushReplacement(AppRoutes.petCardDetail(card.id)); } + } catch (e) { + // 실패 알림은 인터셉터가 띄웠다 — 꾸민 모양을 그대로 두고 다시 시도할 수 있게 한다 + if (!isReportedApiFailure(e)) rethrow; } finally { if (mounted) setState(() => _saving = false); } 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 74a7d79..725ae97 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 @@ -11,6 +11,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/presentation/widgets/pet_picker.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'; import 'package:tma_app/features/pet_card/presentation/widgets/crown_mark.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart'; @@ -145,7 +146,7 @@ Future sharePetCard( return showPetCardShareSheet( context, petName: pet.name, - codeSeed: card.id, + code: displayCardCode(card), capture: capture.capturePng, who: TextSpan(children: [ TextSpan(text: pet.name, style: const TextStyle(color: AppColors.primary600)), 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 4a8b439..4eae291 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 @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/session_store.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_card/data/pet_card_repository.dart'; @@ -24,11 +25,10 @@ class PetCardNotifier extends AsyncNotifier { @override Future build() async { - final repo = ref.watch(petCardRepositoryProvider); - return PetCardState( - cards: await repo.fetchCards(), - representatives: await repo.fetchRepresentatives(), - ); + // 회원이 바뀌면(로그인·로그아웃) 다시 읽는다 + ref.watch(sessionEpochProvider); + final book = await ref.watch(petCardRepositoryProvider).fetch(); + return PetCardState(cards: book.cards, representatives: book.representatives); } PetCardState get _current => state.value ?? const PetCardState(cards: [], representatives: {}); @@ -48,25 +48,22 @@ class PetCardNotifier extends AsyncNotifier { _emit(_current.copyWith(cards: [for (final c in _current.cards) c.id == cardId ? card : c])); } + /// 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 (저장소가 정리한다) Future delete(String cardId) async { - final card = _current.cards.where((c) => c.id == cardId).firstOrNull; await _repo.delete(cardId); - final reps = {..._current.representatives}; - if (card != null && reps[card.petId] == cardId) { - reps.remove(card.petId); - await _repo.clearRepresentative(card.petId); - } _emit(PetCardState( cards: _current.cards.where((c) => c.id != cardId).toList(), - representatives: reps, + representatives: {..._current.representatives}..removeWhere((_, id) => id == cardId), )); } - /// 아이를 삭제하면 그 아이의 명함도 함께 지운다 + /// 아이를 삭제하면 그 아이의 명함도 함께 지운다 (서버 모드는 서버가 이미 지웠다) Future deleteForPet(String petId) async { - for (final card in _current.cards.where((c) => c.petId == petId).toList()) { - await delete(card.id); - } + await _repo.dropPet(petId); + _emit(PetCardState( + cards: _current.cards.where((c) => c.petId != petId).toList(), + representatives: {..._current.representatives}..remove(petId), + )); } Future setRepresentative(PetCard card) async { 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 d0cb880..6dd2c75 100644 --- a/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart +++ b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart @@ -8,7 +8,6 @@ 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/pet_card_facts.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'; @@ -28,7 +27,7 @@ const double _kQrBadge = 54; Future showPetCardShareSheet( BuildContext context, { required String petName, - required String codeSeed, + required String code, required InlineSpan who, required Future Function() capture, }) { @@ -37,7 +36,7 @@ Future showPetCardShareSheet( title: '$petName 펫 명함 공유', builder: (context) => _ShareBody( petName: petName, - code: petCardCode(codeSeed), + code: code, who: who, capture: capture, ), diff --git a/flutter/test/api_pet_card_repository_test.dart b/flutter/test/api_pet_card_repository_test.dart new file mode 100644 index 0000000..39006ba --- /dev/null +++ b/flutter/test/api_pet_card_repository_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; +import 'dart:ui' show Color; + +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/domain/card_style.dart'; + +import 'api_account_test.dart' show kPngBytes; +import 'support/fake_api.dart'; + +Map _cardJson({ + String id = 'c1', + String petId = 'p1', + bool isRepresentative = false, + String? nameBgAsset, + String? nameBgFileId, +}) => + { + 'id': id, + 'petId': petId, + 'code': '0428-117', + 'isRepresentative': isRepresentative, + 'style': { + 'tint': '#5AA0E0', + 'tint2': '#9CCBF0', + 'nameBg': null, + 'nameBgAsset': nameBgAsset, + 'nameBgFileId': nameBgFileId, + 'textLight': true, + 'scrim': true, + 'scrimTone': 'dark', + }, + }; + +void main() { + late FakeApi api; + late ApiPetCardRepository repo; + + setUp(() { + api = FakeApi(); + repo = ApiPetCardRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add)); + }); + + test('명함 목록에서 대표 명함을 아이별로 모으고, 서버가 발급한 코드를 담는다', () async { + api.on('GET', '/pet-cards', (r) => ok(page([ + _cardJson(nameBgAsset: 'assets/svg/card_bg/bg_1.svg'), + _cardJson(id: 'c2', isRepresentative: true, nameBgFileId: 'f1'), + _cardJson(id: 'c3', petId: 'p2'), + ]))); + + final book = await repo.fetch(); + + expect(book.cards.map((c) => c.id), ['c1', 'c2', 'c3']); + expect(book.representatives, {'p1': 'c2'}); + expect(book.cards.first.code, '0428-117'); + expect(book.cards.first.style.nameBgImage, 'assets/svg/card_bg/bg_1.svg'); + expect(book.cards[1].style.nameBgImage, 'server-file:f1'); + expect(book.cards.first.style.scrimTone, ScrimTone.dark); + }); + + test('앱 그림 배경은 경로만 보내고 올리지 않는다', () async { + api.on('PUT', '/pet-cards/:id', (r) { + final style = r.json!['style'] as Map; + expect(style['nameBgAsset'], 'assets/svg/card_bg/bg_2.svg'); + expect(style['nameBgFileId'], isNull); + return ok(_cardJson(nameBgAsset: 'assets/svg/card_bg/bg_2.svg')); + }); + + await repo.update('c1', CardStyle.initial().withBackgroundImage('assets/svg/card_bg/bg_2.svg')); + + expect(api.sent('POST', '/files'), isEmpty); + }); + + test('기기 사진 배경은 올린 뒤 파일 id 로 보낸다', () async { + final dir = Directory.systemTemp.createTempSync('rup-card'); + addTearDown(() => dir.deleteSync(recursive: true)); + final photo = File('${dir.path}/bg.png')..writeAsBytesSync(kPngBytes); + api.on('POST', '/files', (r) => ok({'id': 'f7'}, status: 201)); + api.on('POST', '/pet-cards', (r) { + expect(r.json!['petId'], 'p1'); + final style = r.json!['style'] as Map; + expect(style['nameBgFileId'], 'f7'); + expect(style['nameBgAsset'], isNull); + expect(style['tint'], '#FF9B6A'); + return ok(_cardJson(nameBgFileId: 'f7'), status: 201); + }); + + final card = await repo.create('p1', CardStyle.forPet(const Color(0xFFFF9B6A)).withBackgroundImage(photo.path)); + + expect(card.style.nameBgImage, 'server-file:f7'); + }); + + test('대표 명함은 아이의 하위 경로로 정하고 푼다', () async { + api.on('PUT', '/pets/:petId/representative-card', (r) { + expect(r.json, {'cardId': 'c1'}); + return noContent; + }); + api.on('DELETE', '/pets/:petId/representative-card', (r) => noContent); + + await repo.setRepresentative('p1', 'c1'); + await repo.clearRepresentative('p1'); + + expect(api.sent('PUT', '/pets/p1/representative-card'), hasLength(1)); + expect(api.sent('DELETE', '/pets/p1/representative-card'), hasLength(1)); + }); + + test('아이를 지울 때 명함 정리는 서버가 이미 했다', () async { + await repo.dropPet('p1'); + + expect(api.requests, isEmpty); + }); +} diff --git a/flutter/test/pet_card_domain_test.dart b/flutter/test/pet_card_domain_test.dart index 7f13cf3..f4053c6 100644 --- a/flutter/test/pet_card_domain_test.dart +++ b/flutter/test/pet_card_domain_test.dart @@ -113,21 +113,50 @@ void main() { await repo.create('pet-1', CardStyle.initial()); await repo.update(a.id, CardStyle.initial().withTextLight(true)); - var cards = await repo.fetchCards(); + var cards = (await repo.fetch()).cards; expect(cards, hasLength(2)); expect(cards.first.style.textLight, isTrue); await repo.delete(a.id); - cards = await repo.fetchCards(); + cards = (await repo.fetch()).cards; expect(cards.map((c) => c.id), isNot(contains(a.id))); }); test('대표 명함을 아이별로 기억하고 지울 수 있다', () async { final a = await repo.create('pet-1', CardStyle.initial()); await repo.setRepresentative('pet-1', a.id); - expect(await repo.fetchRepresentatives(), {'pet-1': a.id}); + expect((await repo.fetch()).representatives, {'pet-1': a.id}); await repo.clearRepresentative('pet-1'); - expect(await repo.fetchRepresentatives(), isEmpty); + expect((await repo.fetch()).representatives, isEmpty); + }); + + test('명함을 지우면 그 명함을 가리키던 대표 지정도 사라진다', () async { + final a = await repo.create('pet-1', CardStyle.initial()); + await repo.setRepresentative('pet-1', a.id); + + await repo.delete(a.id); + + expect((await repo.fetch()).representatives, isEmpty); + }); + + test('아이를 지우면 그 아이의 명함과 대표 지정을 모두 지운다', () async { + final a = await repo.create('pet-1', CardStyle.initial()); + await repo.create('pet-2', CardStyle.initial()); + await repo.setRepresentative('pet-1', a.id); + + await repo.dropPet('pet-1'); + + final book = await repo.fetch(); + expect(book.cards.map((c) => c.petId), ['pet-2']); + expect(book.representatives, isEmpty); + }); + + test('명함 코드는 서버가 발급한 값, 없으면 명함 id 로 만든다', () { + final local = PetCard(id: 'c1', petId: 'p1', style: CardStyle.initial()); + const fromServer = PetCard(id: 'c1', petId: 'p1', style: CardStyle(tint: Color(0xFFFF9B6A), tint2: Color(0xFFFF9B6A)), code: '0428-117'); + + expect(displayCardCode(local), petCardCode('c1')); + expect(displayCardCode(fromServer), '0428-117'); }); test('명함 JSON 을 그대로 되살릴 수 있다', () {