From 100dbe3a352679f0caab68f0c0b831470d8c046e Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 18:17:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=9A=B0=EB=A6=AC=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EC=84=9C=EB=B2=84=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20(ApiPetRepository)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다 - 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다 - 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다 - 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다 - DATA_MODE=local 이면 지금까지처럼 기기에 저장한다 Co-Authored-By: Claude Opus 5.5 --- .../data/repositories/api_pet_repository.dart | 129 ++++++++++++++ .../pet/data/repositories/pet_repository.dart | 31 ++-- .../presentation/providers/pet_providers.dart | 10 +- flutter/test/api_pet_repository_test.dart | 165 ++++++++++++++++++ flutter/test/archive_domain_test.dart | 2 +- flutter/test/pet_domain_test.dart | 10 +- 6 files changed, 328 insertions(+), 19 deletions(-) create mode 100644 flutter/lib/features/pet/data/repositories/api_pet_repository.dart create mode 100644 flutter/test/api_pet_repository_test.dart diff --git a/flutter/lib/features/pet/data/repositories/api_pet_repository.dart b/flutter/lib/features/pet/data/repositories/api_pet_repository.dart new file mode 100644 index 0000000..8407387 --- /dev/null +++ b/flutter/lib/features/pet/data/repositories/api_pet_repository.dart @@ -0,0 +1,129 @@ +import 'dart:ui' show Color; + +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/core/utils/date_format.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_archive.dart'; + +/// 반려동물 경로 (backend PetController) +const String _kPetsPath = '/pets'; + +/// 서버 응답(PetResponseDto) → 앱의 Pet +Pet petFromApi(Map json) { + final archive = json['archive'] as Map?; + return Pet( + id: json['id']! as String, + tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first, + name: json['name']! as String, + species: json['species']! as String, + breed: json['breed'] as String? ?? '', + gender: PetGender.values.byName(json['gender']! as String), + neutered: json['neutered']! as bool, + birthDate: parseIsoDate(json['birthDate']! as String), + weight: (json['weight']! as num).toDouble(), + weightUnit: WeightUnit.values.byName(json['weightUnit']! as String), + photoPath: serverFileRefOrNull(json['photoFileId'] as String?), + microchip: json['microchip'] as String?, + bloodType: json['bloodType'] as String?, + vet: json['vet'] as String?, + memo: json['memo'] as String?, + allergies: _strings(json['allergies']), + conditions: _strings(json['conditions']), + watchConditions: _strings(json['watchConditions']), + archive: archive == null + ? null + : PetArchive( + from: parseIsoDate(archive['from']! as String), + to: parseIsoDate(archive['to']! as String), + archivedAt: DateTime.parse(archive['archivedAt']! as String).toLocal(), + memo: archive['memo'] as String?, + ), + ); +} + +List _strings(Object? value) => value is List ? value.whereType().toList() : const []; + +/// 앱의 PetInput → 서버 요청 본문 (사진은 미리 올린 파일 id) +Map petInputToApi(PetInput input, {required String? photoFileId}) => { + 'name': input.name, + 'species': input.species, + 'breed': input.breed, + 'gender': input.gender.name, + 'neutered': input.neutered, + 'birthDate': toIsoDate(input.birthDate), + 'weight': input.weight, + 'weightUnit': input.weightUnit.name, + 'photoFileId': photoFileId, + 'microchip': input.microchip, + 'bloodType': input.bloodType, + 'vet': input.vet, + 'memo': input.memo, + 'allergies': input.allergies, + 'conditions': input.conditions, + 'watchConditions': input.watchConditions, +}; + +/// 서버 저장소 — 아이 사진은 저장할 때 올린다. 아이를 지우거나 보관하면 명함·일정·다이어리는 서버가 함께 정리한다. +class ApiPetRepository implements PetRepository { + ApiPetRepository({required Dio dio, required SessionStore session, required FileUploader uploader}) + : _dio = dio, + _session = session, + _uploader = uploader; + + final Dio _dio; + final SessionStore _session; + final FileUploader _uploader; + + @override + Future> fetchPets() async { + // 로그인 전에는 부르지 않는다 + if (!_session.hasSession) return const []; + return fetchAllPages(_dio, _kPetsPath, petFromApi); + } + + @override + Future create(PetInput input, {required Color tint}) async { + final body = petInputToApi(input, photoFileId: await _uploader.ensureUploaded(input.photoPath)); + final response = await _dio.post>(_kPetsPath, data: {...body, 'tint': hexOf(tint)}); + return petFromApi(response.data!); + } + + @override + Future update(String id, PetInput input) async { + final body = petInputToApi(input, photoFileId: await _uploader.ensureUploaded(input.photoPath)); + final response = await _dio.patch>('$_kPetsPath/$id', data: body); + return petFromApi(response.data!); + } + + @override + Future delete(String id) => _dio.delete('$_kPetsPath/$id'); + + /// 보관한 때(archivedAt)는 서버가 정한다 + @override + Future archive(String id, PetArchive archive) async { + final response = await _dio.put>( + '$_kPetsPath/$id/archive', + data: {'from': toIsoDate(archive.from), 'to': toIsoDate(archive.to), 'memo': archive.memo}, + ); + return petFromApi(response.data!); + } + + @override + Future restore(String id) async { + final response = await _dio.delete>('$_kPetsPath/$id/archive'); + return petFromApi(response.data!); + } + + /// 순서를 저장하고 서버가 정한 순서로 다시 읽는다 + @override + Future> reorder(List ids) async { + await _dio.put('$_kPetsPath/order', data: {'ids': ids}); + return fetchPets(); + } +} diff --git a/flutter/lib/features/pet/data/repositories/pet_repository.dart b/flutter/lib/features/pet/data/repositories/pet_repository.dart index 88af03e..b214462 100644 --- a/flutter/lib/features/pet/data/repositories/pet_repository.dart +++ b/flutter/lib/features/pet/data/repositories/pet_repository.dart @@ -1,17 +1,24 @@ import 'dart:convert'; +import 'dart:ui' show Color; 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/data/repositories/api_pet_repository.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet/domain/pet_archive.dart'; -/// 반려동물 저장소 계약 — 백엔드 API(/api/pets)가 생기면 Dio 구현으로 바꾼다. +/// 반려동물 저장소 계약 — 서버(ApiPetRepository) / 기기(LocalPetRepository) abstract interface class PetRepository { /// 사용자가 정한 순서대로 Future> fetchPets(); - Future create(PetInput input); + /// [tint] 는 앱이 등록 순서대로 정한 아이의 고유색 (petTintFor) + Future create(PetInput input, {required Color tint}); Future update(String id, PetInput input); @@ -29,8 +36,7 @@ abstract interface class PetRepository { const String _kPetsKey = 'pets.v1'; -/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다. -/// 서버 연동 전까지 앱을 다시 켜도 등록한 아이가 남도록 하기 위한 것이다. +/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트). class LocalPetRepository implements PetRepository { LocalPetRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now; @@ -51,13 +57,9 @@ class LocalPetRepository implements PetRepository { Future> fetchPets() async => _read(); @override - Future create(PetInput input) async { + Future create(PetInput input, {required Color tint}) async { final pets = _read(); - final pet = Pet.fromInput( - 'pet-${_clock().microsecondsSinceEpoch}', - input, - tint: petTintFor(pets.length), - ); + final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input, tint: tint); await _write([...pets, pet]); return pet; } @@ -108,5 +110,12 @@ List reorderPets(List pets, List ids) { } final petRepositoryProvider = Provider((ref) { - return LocalPetRepository(ref.watch(sharedPreferencesProvider)); + return switch (ref.watch(dataModeProvider)) { + DataMode.local => LocalPetRepository(ref.watch(sharedPreferencesProvider)), + DataMode.api => ApiPetRepository( + dio: ref.watch(dioProvider), + session: ref.watch(sessionStoreProvider), + uploader: ref.watch(fileUploaderProvider), + ), + }; }); diff --git a/flutter/lib/features/pet/presentation/providers/pet_providers.dart b/flutter/lib/features/pet/presentation/providers/pet_providers.dart index 74662a3..58cc699 100644 --- a/flutter/lib/features/pet/presentation/providers/pet_providers.dart +++ b/flutter/lib/features/pet/presentation/providers/pet_providers.dart @@ -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/data/repositories/pet_repository.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet/domain/pet_archive.dart'; @@ -8,10 +9,15 @@ class PetListNotifier extends AsyncNotifier> { PetRepository get _repo => ref.read(petRepositoryProvider); @override - Future> build() => ref.watch(petRepositoryProvider).fetchPets(); + Future> build() { + // 회원이 바뀌면(로그인·로그아웃) 다시 읽는다 + ref.watch(sessionEpochProvider); + return ref.watch(petRepositoryProvider).fetchPets(); + } Future create(PetInput input) async { - final pet = await _repo.create(input); + // 고유색은 등록 순서대로 — 추억 보관함의 아이까지 센다 + final pet = await _repo.create(input, tint: petTintFor(state.value?.length ?? 0)); if (ref.mounted) state = AsyncData([...?state.value, pet]); return pet; } diff --git a/flutter/test/api_pet_repository_test.dart b/flutter/test/api_pet_repository_test.dart new file mode 100644 index 0000000..787c501 --- /dev/null +++ b/flutter/test/api_pet_repository_test.dart @@ -0,0 +1,165 @@ +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/data/repositories/api_pet_repository.dart'; +import 'package:tma_app/features/pet/domain/pet.dart'; +import 'package:tma_app/features/pet/domain/pet_archive.dart'; + +import 'api_account_test.dart' show kPngBytes; +import 'support/fake_api.dart'; + +Map _petJson({String id = 'p1', String name = '콩이', String? photoFileId, Object? archive}) => { + 'id': id, + 'tint': '#6FB8E8', + 'name': name, + 'species': '강아지', + 'breed': '말티즈', + 'gender': 'female', + 'neutered': true, + 'birthDate': '2021-09-14', + 'weight': 3.2, + 'weightUnit': 'kg', + 'photoFileId': photoFileId, + 'microchip': null, + 'bloodType': 'DEA 1.1', + 'vet': null, + 'memo': '간식 좋아함', + 'allergies': ['닭고기'], + 'conditions': [], + 'watchConditions': ['슬개골'], + 'archive': archive, +}; + +PetInput _input({String? photoPath}) => PetInput( + name: '콩이', + species: '강아지', + breed: '말티즈', + gender: PetGender.female, + neutered: true, + birthDate: DateTime(2021, 9, 14), + weight: 3.2, + weightUnit: WeightUnit.kg, + photoPath: photoPath, + allergies: const ['닭고기'], +); + +void main() { + late FakeApi api; + late ApiPetRepository repo; + + setUp(() { + api = FakeApi(); + repo = ApiPetRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add)); + }); + + test('서버 응답을 앱의 Pet 으로 옮긴다 — 사진은 서버 파일, 보관 기록은 PetArchive', () { + final pet = petFromApi( + _petJson( + photoFileId: 'f1', + archive: {'from': '2021-09-14', 'to': '2026-10-01', 'archivedAt': '2026-10-11T08:00:00.000Z', 'memo': '고마워'}, + ), + ); + + expect(pet.id, 'p1'); + expect(pet.tint, const Color(0xFF6FB8E8)); + expect(pet.gender, PetGender.female); + expect(pet.birthDate, DateTime(2021, 9, 14)); + expect(pet.photoPath, 'server-file:f1'); + expect(pet.allergies, ['닭고기']); + expect(pet.watchConditions, ['슬개골']); + expect(pet.archive?.to, DateTime(2026, 10, 1)); + expect(pet.archive?.memo, '고마워'); + }); + + test('목록은 마지막 페이지까지 이어 읽는다', () async { + api.on( + 'GET', + '/pets', + (r) => + r.query['cursor'] == null ? ok(page([_petJson(id: 'a')], nextCursor: 'c1')) : ok(page([_petJson(id: 'b')])), + ); + + final pets = await repo.fetchPets(); + + expect(pets.map((p) => p.id), ['a', 'b']); + expect(api.sent('GET', '/pets').map((r) => r.query['limit']), [100, 100]); + }); + + test('로그인 전에는 목록을 부르지 않는다', () async { + final signedOut = FakeApi(tokens: null); + final r = ApiPetRepository( + dio: signedOut.dio, + session: signedOut.session, + uploader: FileUploader(signedOut.dio, (_) {}), + ); + + expect(await r.fetchPets(), isEmpty); + expect(signedOut.requests, isEmpty); + }); + + test('등록할 때 기기 사진을 먼저 올리고 고유색과 함께 보낸다', () async { + final dir = Directory.systemTemp.createTempSync('rup-pet'); + addTearDown(() => dir.deleteSync(recursive: true)); + final photo = File('${dir.path}/kong.png')..writeAsBytesSync(kPngBytes); + api.on('POST', '/files', (r) => ok({'id': 'f9'}, status: 201)); + api.on('POST', '/pets', (r) { + expect(r.json?['tint'], '#FF9B6A'); + expect(r.json?['photoFileId'], 'f9'); + expect(r.json?['birthDate'], '2021-09-14'); + expect(r.json?['gender'], 'female'); + return ok(_petJson(photoFileId: 'f9'), status: 201); + }); + + final pet = await repo.create(_input(photoPath: photo.path), tint: const Color(0xFFFF9B6A)); + + expect(pet.photoPath, 'server-file:f9'); + }); + + test('수정할 때 이미 올린 사진은 다시 올리지 않는다', () async { + api.on('PATCH', '/pets/:id', (r) { + expect(r.params['id'], 'p1'); + expect(r.json?['photoFileId'], 'f1'); + expect(r.json!.containsKey('tint'), isFalse); + return ok(_petJson(photoFileId: 'f1')); + }); + + await repo.update('p1', _input(photoPath: 'server-file:f1')); + + expect(api.sent('POST', '/files'), isEmpty); + }); + + test('보관하기는 날짜만 보내고(보관한 때는 서버가 정한다) 다시 불러오기는 보관 기록을 지운다', () async { + api.on('PUT', '/pets/:id/archive', (r) { + expect(r.json, {'from': '2021-09-14', 'to': '2026-10-01', 'memo': null}); + return ok( + _petJson( + archive: {'from': '2021-09-14', 'to': '2026-10-01', 'archivedAt': '2026-10-11T08:00:00.000Z', 'memo': null}, + ), + ); + }); + api.on('DELETE', '/pets/:id/archive', (r) => ok(_petJson())); + + final archived = await repo.archive( + 'p1', + PetArchive(from: DateTime(2021, 9, 14), to: DateTime(2026, 10, 1), archivedAt: DateTime(2026, 10, 11)), + ); + expect(archived.isArchived, isTrue); + expect((await repo.restore('p1')).isArchived, isFalse); + }); + + test('순서를 저장한 뒤 서버 순서로 다시 읽는다', () async { + api.on('PUT', '/pets/order', (r) { + expect(r.json, { + 'ids': ['b', 'a'], + }); + return noContent; + }); + api.on('GET', '/pets', (r) => ok(page([_petJson(id: 'b'), _petJson(id: 'a')]))); + + final pets = await repo.reorder(['b', 'a']); + + expect(pets.map((p) => p.id), ['b', 'a']); + }); +} diff --git a/flutter/test/archive_domain_test.dart b/flutter/test/archive_domain_test.dart index 220585a..51aabdf 100644 --- a/flutter/test/archive_domain_test.dart +++ b/flutter/test/archive_domain_test.dart @@ -74,7 +74,7 @@ void main() { Future repo() async { SharedPreferences.setMockInitialValues({}); final r = LocalPetRepository(await SharedPreferences.getInstance()); - await r.create(samplePet()); + await r.create(samplePet(), tint: kPetTints.first); return r; } diff --git a/flutter/test/pet_domain_test.dart b/flutter/test/pet_domain_test.dart index 6b10ebd..6d113bb 100644 --- a/flutter/test/pet_domain_test.dart +++ b/flutter/test/pet_domain_test.dart @@ -120,22 +120,22 @@ void main() { }); test('등록한 순서대로 목록에 쌓인다', () async { - await repo.create(_input(name: '콩이')); - await repo.create(_input(name: '보리')); + await repo.create(_input(name: '콩이'), tint: kPetTints.first); + await repo.create(_input(name: '보리'), tint: kPetTints.first); 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: '보리')); + final a = await repo.create(_input(name: '콩이'), tint: kPetTints.first); + await repo.create(_input(name: '보리'), tint: kPetTints.first); await repo.update(a.id, _input(name: '콩순이')); expect((await repo.fetchPets()).map((p) => p.name), ['콩순이', '보리']); }); test('삭제하면 목록에서 빠진다', () async { - final a = await repo.create(_input(name: '콩이')); + final a = await repo.create(_input(name: '콩이'), tint: kPetTints.first); await repo.delete(a.id); expect(await repo.fetchPets(), isEmpty); });