feat(flutter): 펫 명함 만들기·상세·관리·공유 구현

홈에서 아이를 등록한 뒤 이어지는 흐름이라 먼저 만든다.
명함은 기기에 저장하고(아이별 대표 명함 지정), 공유는 명함을 이미지로
그려 기기 공유 창과 QR 로 보낸다. 명함 받기는 서버가 필요해 안내만 둔다.

- 아이를 삭제하면 그 아이의 명함도 함께 지운다
- 수정 화면은 아이 목록을 다 불러온 뒤 연다 (새 등록으로 오인 방지)
- 의존성: share_plus, qr_flutter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:49:21 +09:00
parent 2fab3af26d
commit 46bb77df9b
51 changed files with 4467 additions and 203 deletions
+34 -56
View File
@@ -1,66 +1,32 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/app.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/pages/pet_register_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';
/// 로그인·온보딩까지 마친 사용자
const Map<String, Object> _signedIn = {
'entry.introSeen': true,
'entry.onboardingSeen': true,
'auth.demoSession': 'kakao',
};
Future<void> _launchHome(WidgetTester tester, {Map<String, Object> extra = const {}}) async {
tester.view.physicalSize = const Size(402, 874);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
SharedPreferences.setMockInitialValues({..._signedIn, ...extra});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: const RupApp(),
),
);
// 스플래시 이미지 디코딩 → 2.6초 뒤 홈으로
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
await tester.pump(const Duration(seconds: 4));
await _settle(tester);
}
Future<void> _settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
await tester.pump();
}
import 'support/app_harness.dart';
void main() {
testWidgets('아이가 없으면 홈에 등록 안내가 보이고, 누르면 등록 화면으로 간다', (tester) async {
await _launchHome(tester);
await launchSignedIn(tester);
expect(find.text('안녕하세요, 집사님'), findsOneWidget);
expect(find.text('우리 아이를 등록해 주세요'), findsOneWidget);
expect(find.text('오늘은 등록된 일정이 없어요'), findsOneWidget);
await tester.tap(find.text('우리 아이를 등록해 주세요'));
await _settle(tester);
await settle(tester);
expect(find.byType(PetRegisterPage), findsOneWidget);
});
testWidgets('기본 정보 없이 등록을 누르면 빠진 칸마다 오류를 보여 준다', (tester) async {
await _launchHome(tester);
await launchSignedIn(tester);
await tester.tap(find.text('우리 아이를 등록해 주세요'));
await _settle(tester);
await settle(tester);
await tester.ensureVisible(find.text('반려동물 등록').last);
await tester.tap(find.text('반려동물 등록').last);
await _settle(tester);
await settle(tester);
expect(find.text('이름을 입력해주세요.'), findsOneWidget);
expect(find.text('종을 선택해주세요.'), findsOneWidget);
@@ -69,24 +35,36 @@ void main() {
});
testWidgets('등록된 아이가 있으면 아이 목록과 명함 만들기 안내가 보인다', (tester) async {
final pet = Pet.fromInput(
'pet-1',
PetInput(
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: PetGender.male,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: 3.2,
weightUnit: WeightUnit.kg,
),
);
await _launchHome(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])});
final pet = samplePet();
await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])});
expect(find.text('우리 아이를 등록해 주세요'), findsNothing);
expect(find.text('콩이'), findsWidgets);
expect(find.text('명함 만들기'), findsOneWidget);
});
testWidgets('홈에서 명함을 만들면 상세로 가고, 돌아오면 홈에 대표 명함이 보인다', (tester) async {
final pet = samplePet();
await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])});
await tester.tap(find.text('명함 만들기'));
await settle(tester);
expect(find.byType(PetCardEditorPage), findsOneWidget);
await tester.ensureVisible(find.text('펫 명함 만들기'));
await tester.tap(find.text('펫 명함 만들기'));
await settle(tester);
expect(find.byType(PetCardDetailPage), findsOneWidget);
expect(find.text('카드 공유하기'), findsOneWidget);
// 상세히 모드 — 건강 정보가 함께 보인다
expect(find.text('건강 정보'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
expect(find.byType(PetCardDetailPage), findsNothing);
// 빈 슬롯 대신 명함(공유 버튼 포함)이 보인다
expect(find.text('명함 만들기'), findsNothing);
expect(find.bySemanticsLabel('콩이 명함 공유'), findsOneWidget);
});
}
+138
View File
@@ -0,0 +1,138 @@
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.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';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
void main() {
group('색 변환', () {
test('#RRGGBB 와 Color 를 서로 바꿀 수 있다', () {
expect(colorFromHex('#FF7500'), const Color(0xFFFF7500));
expect(colorFromHex('ff7500'), const Color(0xFFFF7500));
expect(hexOf(const Color(0xFFFF7500)), '#FF7500');
});
test('형식이 틀리면 null', () {
expect(colorFromHex('#FFF'), isNull);
expect(colorFromHex('red'), isNull);
expect(colorFromHex(null), isNull);
});
test('lighten 은 흰색과 비율만큼 섞는다 (시안 lightenHex)', () {
expect(hexOf(lighten(const Color(0xFF000000), .5)), '#808080');
expect(hexOf(lighten(const Color(0xFFFF9B6A), 0)), '#FF9B6A');
expect(hexOf(lighten(const Color(0xFFFF9B6A), 1)), '#FFFFFF');
});
});
group('CardStyle', () {
test('새 명함은 첫 번째 헤더색, 흰 배경으로 시작한다', () {
final s = CardStyle.initial();
expect(s.tint, kCardTints.first.a);
expect(s.nameBg, isNull);
expect(s.nameBgImage, isNull);
expect(s.isPresetTint, isTrue);
});
test('배경색을 고르면 배경 이미지가 해제되고, 이미지를 고르면 배경색이 해제된다', () {
final withImage = CardStyle.initial().withBackgroundImage('assets/svg/card_bg/bg_1.svg');
final withColor = withImage.withBackgroundColor(const Color(0xFFFFF7F1));
expect(withColor.nameBg, const Color(0xFFFFF7F1));
expect(withColor.nameBgImage, isNull);
final back = withColor.withBackgroundImage('assets/svg/card_bg/bg_2.svg');
expect(back.nameBgImage, 'assets/svg/card_bg/bg_2.svg');
expect(back.nameBg, isNull);
});
test('이미지 "기본"은 배경색을 건드리지 않는다', () {
final s = CardStyle.initial().withBackgroundColor(const Color(0xFFECF7F2)).withBackgroundImage(null);
expect(s.nameBg, const Color(0xFFECF7F2));
});
test('직접 고른 헤더색은 두 번째 색을 30% 밝게 만든다', () {
final s = CardStyle.initial().withCustomTint(const Color(0xFF000000));
expect(s.isPresetTint, isFalse);
expect(hexOf(s.tint2), hexOf(lighten(const Color(0xFF000000), kCustomTintLighten)));
});
test('JSON 으로 저장했다가 그대로 되살릴 수 있다', () {
final s = CardStyle.initial()
.withCustomTint(const Color(0xFF123456))
.withBackgroundColor(const Color(0xFFABCDEF))
.withTextLight(true)
.withScrim(true)
.withScrimTone(ScrimTone.dark);
expect(CardStyle.fromJson(s.toJson()).toJson(), s.toJson());
});
});
group('명함 표기 값', () {
test('만 나이는 생일 날짜가 지나야 한 살 더한다', () {
expect(petAgeYears(DateTime(2021, 9, 14), DateTime(2026, 9, 13)), 4);
expect(petAgeYears(DateTime(2021, 9, 14), DateTime(2026, 9, 14)), 5);
});
test('사람 나이 환산 — 1살 15세, 2살 24세, 이후 1년에 4세, 1살 미만은 없음', () {
expect(humanAgeOf(0), isNull);
expect(humanAgeOf(1), 15);
expect(humanAgeOf(2), 24);
expect(humanAgeOf(5), 36);
});
test('생일은 yy.MM.dd 로 적는다', () {
expect(shortBirth(DateTime(2021, 9, 4)), '21.09.04');
});
test('명함 코드는 같은 시드면 같고 0000-000 형식이다', () {
final a = petCardCode('card-1');
expect(a, matches(RegExp(r'^\d{4}-\d{3}$')));
expect(petCardCode('card-1'), a);
expect(petCardCode('card-2'), isNot(a));
});
});
group('LocalPetCardRepository', () {
late LocalPetCardRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
var tick = 0;
repo = LocalPetCardRepository(
await SharedPreferences.getInstance(),
clock: () => DateTime.fromMicrosecondsSinceEpoch(++tick),
);
});
test('명함을 만들고 꾸미기 값을 바꾸고 지울 수 있다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.create('pet-1', CardStyle.initial());
await repo.update(a.id, CardStyle.initial().withTextLight(true));
var cards = await repo.fetchCards();
expect(cards, hasLength(2));
expect(cards.first.style.textLight, isTrue);
await repo.delete(a.id);
cards = await repo.fetchCards();
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});
await repo.clearRepresentative('pet-1');
expect(await repo.fetchRepresentatives(), isEmpty);
});
test('명함 JSON 을 그대로 되살릴 수 있다', () {
final card = PetCard(id: 'c1', petId: 'p1', style: CardStyle.initial().withScrim(true));
expect(PetCard.fromJson(card.toJson()).toJson(), card.toJson());
});
});
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/app.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
/// 로그인·온보딩까지 마친 사용자 (카카오 데모 로그인)
const Map<String, Object> kSignedInPrefs = {
'entry.introSeen': true,
'entry.onboardingSeen': true,
'auth.demoSession': 'kakao',
};
/// 테스트용 아이 — 콩이 (말티즈 남아, 2021.09.14)
Pet samplePet({String id = 'pet-1', String name = '콩이'}) => Pet.fromInput(
id,
PetInput(
name: name,
species: '강아지',
breed: '말티즈',
gender: PetGender.male,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: 3.2,
weightUnit: WeightUnit.kg,
),
);
/// 시안 크기 화면에서 로그인한 사용자로 앱을 띄우고 스플래시를 지나 홈까지 간다.
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다.
Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Object> extra = const {}}) async {
tester.view.physicalSize = const Size(402, 874);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
SharedPreferences.setMockInitialValues({...kSignedInPrefs, ...extra});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: const RupApp(),
),
);
// 스플래시 이미지 디코딩 → 2.6초 뒤 홈으로
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
await tester.pump(const Duration(seconds: 4));
await settle(tester);
return prefs;
}
/// 화면 전환이 끝날 때까지 — 새 화면은 첫 프레임에 무대 밖(offstage)이라 몇 번 더 그린다
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
await tester.pump();
}
+2 -3
View File
@@ -109,8 +109,7 @@ void main() {
await tester.tap(find.bySemanticsLabel('펫 명함'));
await _settle(tester);
// 탭 라벨 + 화면 제목
expect(find.text('펫 명함'), findsNWidgets(2));
expect(find.byType(MaterialApp), findsOneWidget);
expect(find.text('우리 아이 명함'), findsOneWidget);
expect(find.text('받은 명함이 없어요.'), findsOneWidget);
});
}