feat(flutter): 우리 아이를 서버에 저장 (ApiPetRepository)

- 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다
- 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다
- 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다
- 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다
- DATA_MODE=local 이면 지금까지처럼 기기에 저장한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:17:43 +09:00
parent 00660c7d62
commit 100dbe3a35
6 changed files with 328 additions and 19 deletions
@@ -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;
}