generated from ttipo/project-base
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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 을 그대로 되살릴 수 있다', () {
|
||||
|
||||
Reference in New Issue
Block a user