generated from ttipo/project-base
feat(flutter): 펫 명함 만들기·상세·관리·공유 구현
홈에서 아이를 등록한 뒤 이어지는 흐름이라 먼저 만든다. 명함은 기기에 저장하고(아이별 대표 명함 지정), 공유는 명함을 이미지로 그려 기기 공유 창과 QR 로 보낸다. 명함 받기는 서버가 필요해 안내만 둔다. - 아이를 삭제하면 그 아이의 명함도 함께 지운다 - 수정 화면은 아이 목록을 다 불러온 뒤 연다 (새 등록으로 오인 방지) - 의존성: share_plus, qr_flutter Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user