feat(flutter): 펫 명함을 서버에 저장 (ApiPetCardRepository)

- 명함 목록·만들기·꾸미기·삭제·대표 명함을 /pet-cards, /pets/:id/representative-card 로 옮겼다
- 명함과 대표 지정을 한 번에 불러오도록 저장소가 PetCardBook 을 돌려준다
- 배경은 앱 그림(assets/…)이면 경로만, 기기 사진이면 올린 뒤 파일 id 로 보낸다
- 공유 화면은 서버가 발급한 명함 코드를 보여 준다 (기기 저장 모드는 지금처럼 명함 id 로 만든다)
- 명함을 지울 때 대표 지정 정리, 아이를 지울 때 명함 정리는 저장소가 맡는다 — 서버 모드는 서버가 이미 했다
- 명함 화면은 실패하면 꾸민 모양을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:32:38 +09:00
parent a09086daf7
commit 2c1d1cc97d
11 changed files with 329 additions and 46 deletions
@@ -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<String, Object?> 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<String, Object?> json) => PetCard(
id: json['id']! as String,
petId: json['petId']! as String,
code: json['code'] as String?,
style: cardStyleFromApi(json['style']! as Map<String, Object?>),
);
/// 서버 저장소 — 배경 사진(기기 사진)은 저장할 때 올리고, 앱 그림은 경로만 보낸다
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<Map<String, Object?>> _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<PetCardBook> fetch() async {
if (!_session.hasSession) return (cards: const <PetCard>[], representatives: const <String, String>{});
final reps = <String, String>{};
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<PetCard> create(String petId, CardStyle style) async {
final response = await _dio.post<Map<String, dynamic>>(
_kCardsPath,
data: {'petId': petId, 'style': await _styleToApi(style)},
);
return petCardFromApi(response.data!);
}
@override
Future<PetCard> update(String cardId, CardStyle style) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kCardsPath/$cardId',
data: {'style': await _styleToApi(style)},
);
return petCardFromApi(response.data!);
}
/// 대표 명함이었으면 서버가 대표 지정도 함께 지운다
@override
Future<void> delete(String cardId) => _dio.delete<void>('$_kCardsPath/$cardId');
@override
Future<void> setRepresentative(String petId, String cardId) =>
_dio.put<void>(_representativePath(petId), data: {'cardId': cardId});
@override
Future<void> clearRepresentative(String petId) => _dio.delete<void>(_representativePath(petId));
/// 서버가 아이를 지우면서 명함도 함께 지웠다
@override
Future<void> dropPet(String petId) async {}
}
@@ -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<PetCard> cards, Map<String, String> representatives});
/// 펫 명함 저장소 계약 — 서버(ApiPetCardRepository) / 기기(LocalPetCardRepository)
abstract interface class PetCardRepository {
Future<List<PetCard>> fetchCards();
Future<PetCardBook> fetch();
Future<PetCard> create(String petId, CardStyle style);
Future<PetCard> update(String cardId, CardStyle style);
/// 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다
Future<void> delete(String cardId);
/// 아이별 대표 명함 (petId → cardId)
Future<Map<String, String>> fetchRepresentatives();
Future<void> setRepresentative(String petId, String cardId);
Future<void> clearRepresentative(String petId);
/// 아이를 지웠을 때 그 아이의 명함을 지운다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 명함도 함께 지웠다.
Future<void> 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<String, Object?>).map((k, v) => MapEntry(k, v! as String));
}
Future<void> _writeRep(Map<String, String> reps) => _prefs.setString(_kRepKey, jsonEncode(reps));
@override
Future<List<PetCard>> fetchCards() async => _read();
Future<PetCardBook> fetch() async => (cards: _read(), representatives: _readRep());
@override
Future<PetCard> create(String petId, CardStyle style) async {
@@ -74,21 +86,24 @@ class LocalPetCardRepository implements PetCardRepository {
@override
Future<void> 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<Map<String, String>> fetchRepresentatives() async => _readRep();
Future<void> setRepresentative(String petId, String cardId) => _writeRep({..._readRep(), petId: cardId});
@override
Future<void> setRepresentative(String petId, String cardId) =>
_prefs.setString(_kRepKey, jsonEncode({..._readRep(), petId: cardId}));
Future<void> clearRepresentative(String petId) => _writeRep(_readRep()..remove(petId));
@override
Future<void> clearRepresentative(String petId) =>
_prefs.setString(_kRepKey, jsonEncode({..._readRep()}..remove(petId)));
Future<void> 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<PetCardRepository>((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<CardBackgroundRepository>((ref) {
@@ -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<String, Object?> toJson() => {'id': id, 'petId': petId, 'style': style.toJson()};
PetCard copyWith({CardStyle? style}) => PetCard(id: id, petId: petId, style: style ?? this.style, code: code);
Map<String, Object?> toJson() => {'id': id, 'petId': petId, 'style': style.toJson(), 'code': ?code};
factory PetCard.fromJson(Map<String, Object?> json) => PetCard(
id: json['id']! as String,
petId: json['petId']! as String,
style: CardStyle.fromJson((json['style'] as Map<String, Object?>?) ?? const {}),
code: json['code'] as String?,
);
}
@@ -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);
@@ -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<PetCardDetailPage> {
final _capture = CaptureController();
Future<void> _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<PetCardDetailPage> {
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<PetCardDetailPage> {
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('펫 명함을 삭제했어요');
}
@@ -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<void> _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);
}
@@ -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<void> 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)),
@@ -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<PetCardState> {
@override
Future<PetCardState> 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<PetCardState> {
_emit(_current.copyWith(cards: [for (final c in _current.cards) c.id == cardId ? card : c]));
}
/// 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 (저장소가 정리한다)
Future<void> 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<void> 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<void> setRepresentative(PetCard card) async {
@@ -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<void> showPetCardShareSheet(
BuildContext context, {
required String petName,
required String codeSeed,
required String code,
required InlineSpan who,
required Future<Uint8List?> Function() capture,
}) {
@@ -37,7 +36,7 @@ Future<void> showPetCardShareSheet(
title: '$petName 펫 명함 공유',
builder: (context) => _ShareBody(
petName: petName,
code: petCardCode(codeSeed),
code: code,
who: who,
capture: capture,
),
@@ -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<String, Object?> _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<String, dynamic>;
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<String, dynamic>;
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);
});
}
+33 -4
View File
@@ -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 을 그대로 되살릴 수 있다', () {