generated from ttipo/project-base
feat(flutter): 우리 아이를 서버에 저장 (ApiPetRepository)
- 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다 - 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다 - 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다 - 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다 - DATA_MODE=local 이면 지금까지처럼 기기에 저장한다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, Object?> json) {
|
||||
final archive = json['archive'] as Map<String, Object?>?;
|
||||
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<String> _strings(Object? value) => value is List ? value.whereType<String>().toList() : const [];
|
||||
|
||||
/// 앱의 PetInput → 서버 요청 본문 (사진은 미리 올린 파일 id)
|
||||
Map<String, Object?> 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<List<Pet>> fetchPets() async {
|
||||
// 로그인 전에는 부르지 않는다
|
||||
if (!_session.hasSession) return const [];
|
||||
return fetchAllPages(_dio, _kPetsPath, petFromApi);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pet> create(PetInput input, {required Color tint}) async {
|
||||
final body = petInputToApi(input, photoFileId: await _uploader.ensureUploaded(input.photoPath));
|
||||
final response = await _dio.post<Map<String, dynamic>>(_kPetsPath, data: {...body, 'tint': hexOf(tint)});
|
||||
return petFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pet> update(String id, PetInput input) async {
|
||||
final body = petInputToApi(input, photoFileId: await _uploader.ensureUploaded(input.photoPath));
|
||||
final response = await _dio.patch<Map<String, dynamic>>('$_kPetsPath/$id', data: body);
|
||||
return petFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) => _dio.delete<void>('$_kPetsPath/$id');
|
||||
|
||||
/// 보관한 때(archivedAt)는 서버가 정한다
|
||||
@override
|
||||
Future<Pet> archive(String id, PetArchive archive) async {
|
||||
final response = await _dio.put<Map<String, dynamic>>(
|
||||
'$_kPetsPath/$id/archive',
|
||||
data: {'from': toIsoDate(archive.from), 'to': toIsoDate(archive.to), 'memo': archive.memo},
|
||||
);
|
||||
return petFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pet> restore(String id) async {
|
||||
final response = await _dio.delete<Map<String, dynamic>>('$_kPetsPath/$id/archive');
|
||||
return petFromApi(response.data!);
|
||||
}
|
||||
|
||||
/// 순서를 저장하고 서버가 정한 순서로 다시 읽는다
|
||||
@override
|
||||
Future<List<Pet>> reorder(List<String> ids) async {
|
||||
await _dio.put<void>('$_kPetsPath/order', data: {'ids': ids});
|
||||
return fetchPets();
|
||||
}
|
||||
}
|
||||
@@ -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<List<Pet>> fetchPets();
|
||||
|
||||
Future<Pet> create(PetInput input);
|
||||
/// [tint] 는 앱이 등록 순서대로 정한 아이의 고유색 (petTintFor)
|
||||
Future<Pet> create(PetInput input, {required Color tint});
|
||||
|
||||
Future<Pet> 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<List<Pet>> fetchPets() async => _read();
|
||||
|
||||
@override
|
||||
Future<Pet> create(PetInput input) async {
|
||||
Future<Pet> 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<Pet> reorderPets(List<Pet> pets, List<String> ids) {
|
||||
}
|
||||
|
||||
final petRepositoryProvider = Provider<PetRepository>((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),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<List<Pet>> {
|
||||
PetRepository get _repo => ref.read(petRepositoryProvider);
|
||||
|
||||
@override
|
||||
Future<List<Pet>> build() => ref.watch(petRepositoryProvider).fetchPets();
|
||||
Future<List<Pet>> build() {
|
||||
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
|
||||
ref.watch(sessionEpochProvider);
|
||||
return ref.watch(petRepositoryProvider).fetchPets();
|
||||
}
|
||||
|
||||
Future<Pet> 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;
|
||||
}
|
||||
|
||||
@@ -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<String, Object?> _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': <String>[],
|
||||
'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']);
|
||||
});
|
||||
}
|
||||
@@ -74,7 +74,7 @@ void main() {
|
||||
Future<LocalPetRepository> repo() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final r = LocalPetRepository(await SharedPreferences.getInstance());
|
||||
await r.create(samplePet());
|
||||
await r.create(samplePet(), tint: kPetTints.first);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user