feat(flutter): 반려동물 등록·수정 화면과 기기 저장소 구현

시안 screen-register · pet-species 를 옮겼다.
- 사진(앨범 선택 → 앱 폴더로 복사), 이름, 종·품종 검색 시트(12 대분류), 성별·중성화,
  생년월일 휠, 몸무게(kg/g), 건강 정보, 알레르기(직접 추가 항목은 다음 등록에도 유지), 메모
- 필수값이 빠지면 칸마다 오류를 표시하고 첫 빠진 칸으로 스크롤
- 수정 모드에서 삭제(확인창) 지원
- 저장은 PetRepository 계약 뒤의 LocalPetRepository(SharedPreferences JSON) —
  백엔드 /api/pets 가 생기면 Dio 구현으로 바꾼다
- image_picker · path_provider 추가, iOS 사진 접근 문구(NSPhotoLibraryUsageDescription)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:35:39 +09:00
parent 544281ed8e
commit 50d1aeea4b
42 changed files with 2023 additions and 4 deletions
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
PetInput _input({String name = '콩이', double weight = 3.2}) => PetInput(
name: name,
species: '강아지',
breed: '말티즈',
gender: PetGender.male,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: weight,
weightUnit: WeightUnit.kg,
allergies: const ['닭고기'],
conditions: const ['슬개골 탈구'],
);
void main() {
group('petAgeLabel', () {
test('1살이 안 되면 개월 수만 보여 준다', () {
expect(petAgeLabel(DateTime(2026, 3, 10), DateTime(2026, 9, 28)), '6개월');
});
test('1살 이상이면 "N살 M개월" 로 보여 준다', () {
expect(petAgeLabel(DateTime(2021, 9, 14), DateTime(2026, 9, 28)), '5살 0개월');
});
test('이번 달 생일 날짜가 아직 안 왔으면 한 달을 덜 센다', () {
expect(petAgeLabel(DateTime(2025, 9, 30), DateTime(2026, 9, 28)), '11개월');
});
test('미래 날짜는 0개월로 처리한다', () {
expect(petAgeLabel(DateTime(2027, 1, 1), DateTime(2026, 9, 28)), '0개월');
});
});
group('한국어 조사', () {
test('받침이 있으면 을, 없으면 를', () {
expect(withObjectParticle('콩이'), '콩이를');
expect(withObjectParticle('사랑'), '사랑을');
});
test('한글이 아닌 이름은 를 로 붙인다', () {
expect(withObjectParticle('Max'), 'Max를');
});
test('주격 조사 — 받침이 있으면 이, 없으면 가', () {
expect(withSubjectParticle('초코'), '초코가');
expect(withSubjectParticle('별'), '별이');
});
});
group('날짜 표기', () {
test('시안 형식으로 표기한다', () {
final d = DateTime(2021, 9, 4);
expect(formatYmd(d), '2021.09.04');
expect(formatYmdSpaced(d), '2021 . 09 . 04');
expect(formatMonthDay(d), '9월 4일');
expect(weekdayKo(DateTime(2026, 9, 28)), '월');
});
test('월별 일 수는 윤년을 반영한다', () {
expect(daysInMonth(2024, 2), 29);
expect(daysInMonth(2025, 2), 28);
expect(daysInMonth(2025, 12), 31);
});
test('ISO 날짜 문자열과 서로 바꿀 수 있다', () {
final d = DateTime(2021, 9, 14);
expect(parseIsoDate(toIsoDate(d)), d);
});
});
group('품종 선택값', () {
test('종 모름 항목은 괄호 설명을 뗀 값으로 저장한다', () {
expect(breedValueOf(kUnknownBreed), kUnknownBreedValue);
});
test('직접 입력은 세부 품종을 비워 둔다', () {
expect(breedValueOf(kCustomBreed), '');
});
test('모든 대분류의 선택 목록 맨 위에는 종 모름 항목이 있다', () {
for (final c in kPetCategories) {
expect(c.choices.first, kUnknownBreed);
}
});
});
group('Pet', () {
test('몸무게 표기는 소수점 끝의 0 을 떼어 낸다', () {
expect(Pet.fromInput('a', _input()).weightLabel, '3.2kg');
expect(Pet.fromInput('a', _input(weight: 4)).weightLabel, '4kg');
});
test('JSON 으로 저장했다가 그대로 되살릴 수 있다', () {
final pet = Pet.fromInput('pet-1', _input());
final restored = Pet.fromJson(pet.toJson());
expect(restored.toJson(), pet.toJson());
});
});
group('LocalPetRepository', () {
late LocalPetRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
var tick = 0;
repo = LocalPetRepository(
await SharedPreferences.getInstance(),
clock: () => DateTime.fromMicrosecondsSinceEpoch(++tick),
);
});
test('등록한 순서대로 목록에 쌓인다', () async {
await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
final pets = await repo.fetchPets();
expect(pets.map((p) => p.name), ['콩이', '보리']);
expect(pets.map((p) => p.id).toSet().length, 2);
});
test('수정하면 같은 자리의 값이 바뀐다', () async {
final a = await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
await repo.update(a.id, _input(name: '콩순이'));
expect((await repo.fetchPets()).map((p) => p.name), ['콩순이', '보리']);
});
test('삭제하면 목록에서 빠진다', () async {
final a = await repo.create(_input(name: '콩이'));
await repo.delete(a.id);
expect(await repo.fetchPets(), isEmpty);
});
});
}