feat(flutter): 추억 보관함 — 보관하기·다시 보기·다시 불러오기

반려동물 정보에서 아이를 추억 보관함으로 옮기고(함께한 기간·메모), 마이페이지의 보관함에서
메모리얼 사진·추억 기록과 원래 정보·펫 명함·다이어리(읽기 전용)를 다시 보며, 다시 불러올 수 있게 했다.

보관 기록은 반려동물에 붙여 저장한다(Pet.archive) — 서버가 생기면 보관/복구 요청으로 바꾼다.
보관하면 그 아이의 일정은 지우고(여러 아이 일정은 그 아이만 뗀다), 홈·나의 반려동물·펫 명함·일정 대상에서
빼며, 그 아이만 담은 다이어리는 책장·홈 최근 기록·기록 알림과 다이어리 수 한도에서 뺀다.

화면은 petListProvider 를 직접 보고 목록 확장(active/archived)으로 거른다 — 걸러 낸 목록을
파생 프로바이더로 두면 Riverpod 3.3 의 일시정지 해제 때 markNeedsBuild 오류가 난다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 00:09:45 +09:00
parent 795509be1b
commit 03cb0224d6
40 changed files with 2273 additions and 256 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/korean.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
import 'package:tma_app/features/diary/domain/mood.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';
import 'support/app_harness.dart';
final PetArchive _archive = PetArchive(
from: DateTime(2021, 9, 14),
to: DateTime(2026, 9, 28),
archivedAt: DateTime(2026, 9, 28, 21, 5),
memo: '고마웠어',
);
Diary _diary(String id, List<String> targets) =>
Diary(id: id, name: id, targetIds: targets, ribbon: kRibbonColors.first, tint: kDiaryTints.first);
DiaryPost _post(String id, String diaryId, DateTime date) =>
DiaryPost(id: id, diaryId: diaryId, date: date, createdAt: date, text: id, mood: Mood.happy);
void main() {
group('togetherSpan', () {
test('해와 달로 읽는다', () {
expect(togetherSpan(DateTime(2018, 6, 1), DateTime(2026, 9, 1)), '8년 3개월');
expect(togetherSpan(DateTime(2024, 3, 10), DateTime(2026, 3, 10)), '2년');
expect(togetherSpan(DateTime(2026, 4, 1), DateTime(2026, 9, 15)), '5개월');
});
test('마지막 날이 첫날의 날짜보다 이르면 그 달은 세지 않는다', () {
expect(togetherSpan(DateTime(2021, 9, 14), DateTime(2026, 9, 13)), '4년 11개월');
});
test('한 달이 안 되면 "한 달"', () {
expect(togetherSpan(DateTime(2026, 9, 1), DateTime(2026, 9, 20)), '한 달');
expect(togetherSpan(DateTime(2026, 9, 20), DateTime(2026, 9, 1)), '한 달');
});
});
test('공동격 조사는 받침에 따라 와/과', () {
expect(withAndParticle('콩이'), '콩이와');
expect(withAndParticle('사랑'), '사랑과');
expect(withAndParticle('Coco'), 'Coco와');
});
group('보관 기록 저장', () {
test('보관 기록은 JSON 으로 오가도 같다', () {
final back = PetArchive.fromJson(_archive.toJson());
expect(back.from, _archive.from);
expect(back.to, _archive.to);
expect(back.archivedAt, _archive.archivedAt);
expect(back.memo, '고마웠어');
expect(back.spanLabel, '5년');
});
test('보관한 아이는 보관 기록까지 저장되고, 함께 지내는 아이는 보관 항목이 없다', () {
final pet = samplePet();
expect(pet.toJson().containsKey('archive'), isFalse);
expect(Pet.fromJson(pet.toJson()).isArchived, isFalse);
final archived = Pet.fromJson(pet.withArchive(_archive).toJson());
expect(archived.isArchived, isTrue);
expect(archived.archive!.memo, '고마웠어');
expect(archived.name, pet.name);
expect(archived.tint, pet.tint);
});
});
group('LocalPetRepository — 보관·복구', () {
Future<LocalPetRepository> repo() async {
SharedPreferences.setMockInitialValues({});
final r = LocalPetRepository(await SharedPreferences.getInstance());
await r.create(samplePet());
return r;
}
test('보관하면 보관 기록이 붙고, 다시 불러오면 떨어진다', () async {
final r = await repo();
final id = (await r.fetchPets()).single.id;
await r.archive(id, _archive);
expect((await r.fetchPets()).single.isArchived, isTrue);
await r.restore(id);
expect((await r.fetchPets()).single.isArchived, isFalse);
});
test('정보를 수정해도 보관 기록은 그대로다', () async {
final r = await repo();
final id = (await r.fetchPets()).single.id;
await r.archive(id, _archive);
final updated = await r.update(id, samplePet(name: '콩순이'));
expect(updated.name, '콩순이');
expect(updated.isArchived, isTrue);
});
});
group('보관된 아이의 다이어리', () {
final book = DiaryBook(
diaries: [
_diary('only', ['pet-1']),
_diary('mixed', ['pet-1', 'pet-2']),
_diary('other', ['pet-2']),
],
posts: [
_post('a', 'only', DateTime(2026, 9, 20)),
_post('b', 'mixed', DateTime(2026, 9, 21)),
_post('c', 'other', DateTime(2026, 9, 22)),
],
);
test('보관된 아이만 담은 다이어리는 책장에서 빠진다', () {
expect(book.shelf({'pet-1'}).map((d) => d.id), ['mixed', 'other']);
expect(book.shelf().map((d) => d.id), ['only', 'mixed', 'other']);
});
test('책장이 가득 찼는지는 보관함으로 간 다이어리를 빼고 센다', () {
final full = DiaryBook(diaries: [
for (var i = 0; i < kMaxDiaries - 1; i++) _diary('d$i', ['pet-2']),
_diary('gone', ['pet-1']),
]);
expect(full.atMax(), isTrue);
expect(full.atMax({'pet-1'}), isFalse);
});
test('아이가 담긴 다이어리와 그 기록 수', () {
expect(book.diariesOf('pet-1').map((d) => d.id), ['only', 'mixed']);
expect(book.postCountOf('pet-1'), 2);
});
test('최근 기록은 고른 다이어리에서만 뽑을 수 있다', () {
expect(book.latest(3).map((p) => p.id), ['c', 'b', 'a']);
expect(book.latest(3, diaryIds: {'mixed', 'other'}).map((p) => p.id), ['c', 'b']);
});
});
}
+192
View File
@@ -0,0 +1,192 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/archive/presentation/pages/archive_list_page.dart';
import 'package:tma_app/features/archive/presentation/pages/pet_archive_move_page.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
import 'package:tma_app/features/pet/domain/pet_archive.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/presentation/widgets/name_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'support/app_harness.dart';
final DateTime _today = DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day);
final Diary _diary = Diary(
id: 'd1',
name: '콩이 성장일기',
targetIds: const ['pet-1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
);
final DiaryPost _post = DiaryPost(
id: 'p1',
diaryId: 'd1',
date: DateTime(2026, 9, 1),
createdAt: DateTime(2026, 9, 1, 9),
text: '공원 산책',
mood: Mood.happy,
);
final ScheduleEvent _walk = ScheduleEvent(
id: 'e1',
date: _today,
details: const EventDetails(
title: '저녁 산책',
kind: EventKind.general,
category: EventCategory.walk,
targetIds: ['pet-1'],
start: HourMinute(19, 0),
),
);
final PetArchive _archive = PetArchive(
from: DateTime(2021, 9, 14),
to: DateTime(2026, 9, 14),
archivedAt: DateTime(2026, 9, 14, 21),
memo: '고마웠어 콩이야',
);
/// 콩이 + 다이어리·기록·명함·일정. [archived] 면 콩이는 이미 추억 보관함에 있다
Map<String, Object> _prefs({bool archived = false}) {
final pet = archived ? samplePet().withArchive(_archive) : samplePet();
return {
'pets.v1': jsonEncode([pet.toJson()]),
'diary.diaries.v1': jsonEncode([_diary.toJson()]),
'diary.posts.v1': jsonEncode([_post.toJson()]),
'petCards.v1': jsonEncode([PetCard(id: 'c1', petId: 'pet-1', style: CardStyle.initial()).toJson()]),
'schedule.events.v1': jsonEncode([_walk.toJson()]),
};
}
/// 목록 아래쪽 줄은 스크롤해야 만들어진다 — 보일 때까지 내려서 누른다
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
await tester.scrollUntilVisible(finder, 200, scrollable: find.byType(Scrollable).first);
await tester.pump();
await tester.tap(finder);
await settle(tester);
}
Future<void> _openMyPage(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('마이페이지'));
await settle(tester);
}
Future<void> _openArchive(WidgetTester tester) async {
await _openMyPage(tester);
await _tapVisible(tester, find.text('보관된 아이'));
expect(find.byType(ArchiveListPage), findsOneWidget);
}
void main() {
testWidgets('반려동물 정보에서 추억 보관함으로 옮기면 일정은 지워지고 홈·책장에서 빠진다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _prefs());
await _openMyPage(tester);
await tester.tap(find.bySemanticsLabel('반려동물 1마리'));
await settle(tester);
await tester.tap(find.text('콩이'));
await settle(tester);
await _tapVisible(tester, find.text('추억 보관함으로'));
expect(find.text('콩이를 추억 보관함으로'), findsOneWidget);
await tester.tap(find.text('보관하기'));
await settle(tester);
expect(find.byType(PetArchiveMovePage), findsOneWidget);
expect(find.text('콩이와 함께한 기간을 알려주세요'), findsOneWidget);
expect(find.text('2021.09.14'), findsOneWidget);
await tester.enterText(find.byType(TextField), '고마웠어');
await tester.tap(find.text('추억 보관함으로 옮기기'));
await settle(tester);
// 마이페이지로 돌아와 보관함에 한 마리
expect(find.text('추억보관함에 보관했어요.'), findsOneWidget);
expect(find.bySemanticsLabel('반려동물 0마리'), findsOneWidget);
await tester.scrollUntilVisible(find.text('보관된 아이'), 200, scrollable: find.byType(Scrollable).first);
expect(find.text('1마리'), findsOneWidget);
final saved = prefs.getString('pets.v1')!;
expect(saved, contains('"archive"'));
expect(saved, contains('고마웠어'));
// 콩이만의 일정은 지운다
expect(prefs.getString('schedule.events.v1'), isNot(contains('저녁 산책')));
// 홈에는 아이 등록 안내, 다이어리 책장에서도 빠진다
await tester.tap(find.bySemanticsLabel('홈'));
await settle(tester);
expect(find.text('우리 아이를 등록해 주세요'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('일정·기록'));
await settle(tester);
await tester.tap(find.text('다이어리'));
await settle(tester);
expect(find.text('콩이 성장일기'), findsNothing);
expect(find.text('0/6'), findsOneWidget);
});
testWidgets('보관된 아이를 누르면 함께한 기간과 메모, 원래 정보·명함·다이어리를 다시 본다', (tester) async {
await launchSignedIn(tester, extra: _prefs(archived: true));
await _openArchive(tester);
expect(find.text('1마리'), findsOneWidget);
expect(find.text('강아지 · 말티즈 · 기록 1개'), findsOneWidget);
expect(find.text('2021.09.14 ~ 2026.09.14'), findsOneWidget);
await tester.tap(find.text('콩이'));
await settle(tester);
expect(find.text('5년 함께했어요'), findsOneWidget);
expect(find.text('고마웠어 콩이야'), findsOneWidget);
expect(find.text('함께한 펫 명함 1개'), findsOneWidget);
await _tapVisible(tester, find.text('기본·건강 정보를 다시 봐요.'));
expect(find.text('콩이의 정보'), findsOneWidget);
expect(find.text('기본 정보'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
await _tapVisible(tester, find.text('함께한 펫 명함 1개'));
expect(find.byType(NameCard), findsOneWidget);
await tester.tap(find.byType(NameCard));
await settle(tester);
expect(find.byType(PetIdCard), findsOneWidget);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
await _tapVisible(tester, find.text('함께한 날들을 다시 봐요.'));
expect(find.text('다이어리 · 1권'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('콩이 성장일기, 기록 1개'));
await settle(tester);
// 읽기만 — 쓰기·설정 버튼이 없다
expect(find.byType(DiaryPage), findsOneWidget);
expect(find.text('공원 산책'), findsOneWidget);
expect(find.bySemanticsLabel('다이어리 쓰기'), findsNothing);
expect(find.bySemanticsLabel('다이어리 설정'), findsNothing);
});
testWidgets('다시 불러오면 보관함에서 빠지고 나의 반려동물로 돌아온다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _prefs(archived: true));
await _openArchive(tester);
await tester.tap(find.text('콩이'));
await settle(tester);
await _tapVisible(tester, find.text('콩이 다시 불러오기'));
expect(find.text('다시 불러왔어요'), findsOneWidget);
expect(find.byType(ArchiveListPage), findsOneWidget);
expect(find.text('보관된 아이가 없어요'), findsOneWidget);
expect(prefs.getString('pets.v1'), isNot(contains('"archive"')));
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
expect(find.bySemanticsLabel('반려동물 1마리'), findsOneWidget);
});
}
+1 -1
View File
@@ -94,7 +94,7 @@ void main() {
test('다이어리는 6권까지', () {
final book = DiaryBook(diaries: [for (var i = 0; i < kMaxDiaries; i++) _diary('d$i')]);
expect(book.atMax, isTrue);
expect(book.atMax(), isTrue);
});
});