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
@@ -0,0 +1,184 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
import 'package:tma_app/shared/widgets/grayscale.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
/// 목록의 아이 사진은 조금 더 바랜다 (시안 grayscale(.35) · opacity .92)
const double _kRowFade = .35;
const double _kRowOpacity = .92;
/// 추억 보관함 — 보관된 아이 목록
class ArchiveListPage extends ConsumerWidget {
const ArchiveListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).archived;
final book = ref.watch(diaryBookProvider);
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(
title: '추억 보관함',
subtitle: '${pets.length}마리',
titleLeading: const MemoryMark(size: 18),
bottomGap: AppSpacing.lg,
onBack: () => context.pop(),
),
),
ScreenPadding(
child: Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 14),
child: Text(
'먼저 떠난 아이를 삭제하지 않고 보관해요. 아이를 누르면 함께한 다이어리와 펫 명함을 다시 볼 수 있어요.',
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.55),
),
),
),
ScreenPadding(
child: RupCard(
child: pets.isEmpty
? const _Empty()
: Column(
children: [
for (final (i, p) in pets.indexed)
_ArchivedPetRow(
pet: p,
postCount: book.postCountOf(p.id),
first: i == 0,
onTap: () => unawaited(context.push(AppRoutes.archivedPet(p.id))),
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: 34),
child: Opacity(
opacity: .8,
child: Column(
children: [
const Opacity(opacity: .7, child: MemoryMark(size: 30)),
const SizedBox(height: AppSpacing.sm),
Text(
'보관된 아이가 없어요',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.memory,
height: _kNormal,
),
),
],
),
),
);
}
}
/// 보관된 아이 한 줄 — 바랜 사진 · 이름 · 종 · 기록 수 · 함께한 기간
class _ArchivedPetRow extends StatelessWidget {
const _ArchivedPetRow({required this.pet, required this.postCount, required this.first, required this.onTap});
final Pet pet;
final int postCount;
final bool first;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final archive = pet.archive;
final kind = pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}';
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
Opacity(
opacity: _kRowOpacity,
child: Grayscale(amount: _kRowFade, child: PetAvatar(pet: pet, size: 46)),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: textTheme.titleSmall!.copyWith(fontSize: 15.5, height: _kNormal),
),
Text(
'$kind · 기록 $postCount개',
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.memory, height: _kNormal),
),
if (archive != null) ...[
const SizedBox(height: 3),
Row(
children: [
RupIcon(
RupIconName.calendar,
size: 12,
stroke: 2,
color: AppColors.memory.withValues(alpha: .8),
),
const SizedBox(width: AppSpacing.xs),
Flexible(
child: Text(
'${formatYmd(archive.from)} ~ ${formatYmd(archive.to)}',
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal),
),
),
],
),
],
],
),
),
RupIcon(RupIconName.chevR, size: 17, color: AppColors.memory.withValues(alpha: .7)),
],
),
),
),
);
}
}
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.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/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이의 펫 명함 — 간략 명함 목록, 누르면 상세
class ArchivedCardsPage extends ConsumerWidget {
const ArchivedCardsPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final cards = ref.watch(cardsOfPetProvider(pet.id));
return TabSubPage(
children: [
ScreenPadding(
child: ArchiveSubHeader(
title: '펫 명함',
petName: pet.name,
// 시안: 안내 문구가 머리 아래 18 에서 8 을 당겨 붙는다
bottomGap: cards.isEmpty ? 18 : 18 - AppSpacing.sm,
),
),
if (cards.isEmpty)
const ScreenPadding(child: RupCard(child: EmptyMessage('만든 펫 명함이 없어요')))
else ...[
ScreenPadding(
child: Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 14),
child: Text(
'명함을 누르면 상세 펫 명함을 볼 수 있어요.',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
color: AppColors.muted,
height: 1.55,
),
),
),
),
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(height: AppSpacing.md),
ScreenPadding(
child: GestureDetector(
onTap: () => unawaited(context.push(AppRoutes.archivedPetCard(pet.id, c.id))),
child: NameCard(pet: pet, style: c.style),
),
),
],
],
const SizedBox(height: 14),
],
);
}
}
/// 보관된 아이의 펫 명함 상세 — 보기만 한다 (공유·수정·대표 지정 없음)
class ArchivedCardDetailPage extends ConsumerWidget {
const ArchivedCardDetailPage({super.key, required this.petId, required this.cardId});
final String petId;
final String cardId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final card = ref.watch(cardsOfPetProvider(pet.id)).where((c) => c.id == cardId).firstOrNull;
return TabSubPage(
children: [
ScreenPadding(child: ArchiveSubHeader(title: pet.name, petName: pet.name)),
if (card == null)
const ScreenPadding(child: RupCard(child: EmptyMessage('만든 펫 명함이 없어요')))
else
ScreenPadding(child: PetIdCard(pet: pet, style: card.style)),
const SizedBox(height: 14),
],
);
}
}
@@ -0,0 +1,55 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_cover.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이와 함께한 다이어리 — 표지를 누르면 읽기 전용 피드
class ArchivedDiariesPage extends ConsumerWidget {
const ArchivedDiariesPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final book = ref.watch(diaryBookProvider);
final diaries = book.diariesOf(pet.id);
return TabSubPage(
children: [
ScreenPadding(
child: ArchiveSubHeader(
title: diaries.isEmpty ? '다이어리' : '다이어리 · ${diaries.length}권',
petName: pet.name,
),
),
ScreenPadding(
child: diaries.isEmpty
? const RupCard(child: EmptyMessage('함께한 다이어리가 없어요'))
: DiaryGrid(
children: [
for (final d in diaries)
DiaryCoverTile(
diary: d,
postCount: book.countOf(d.id),
showTargets: false,
onTap: () => unawaited(context.push(AppRoutes.archivedPetDiary(pet.id, d.id))),
),
],
),
),
const SizedBox(height: 14),
],
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_info_sections.dart';
import 'package:tma_app/shared/widgets/grayscale.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이의 원래 정보 — 기본·건강 정보를 읽기 전용으로
class ArchivedPetInfoPage extends ConsumerWidget {
const ArchivedPetInfoPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(child: ArchiveSubHeader(title: '${pet.name}의 정보', petName: pet.name)),
ScreenPadding(
child: RupCard(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Grayscale(amount: kArchiveFade, child: PetAvatar(pet: pet, size: 64)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: const TextStyle(fontFamily: AppFonts.display, fontSize: 22, height: 1, color: AppColors.ink),
),
const SizedBox(height: 5),
Text(
pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
],
),
),
],
),
),
),
...petInfoSections(pet),
const SizedBox(height: AppSpacing.xs),
],
);
}
}
@@ -0,0 +1,411 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/archive/presentation/providers/archive_actions.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
const double _kHeroMinHeight = 200;
/// 사진 위 글씨가 읽히게 아래로 갈수록 어두워지는 막 (시안 linear-gradient)
const List<Color> _kHeroShade = [Color(0x0D000000), Color(0x6B000000), Color(0xC7000000)];
const List<double> _kHeroShadeStops = [.3, .62, 1];
const List<Shadow> _kHeroTextShadow = [Shadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 1))];
/// 보관된 아이 — 메모리얼 사진, 추억 기록, 둘러보기, 다시 불러오기
class ArchivedPetPage extends ConsumerWidget {
const ArchivedPetPage({super.key, required this.petId});
final String petId;
Future<void> _restore(BuildContext context, WidgetRef ref) async {
await ref.read(archiveActionsProvider).restore(petId);
if (context.mounted) context.go(AppRoutes.archive);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
final archive = pet?.archive;
if (pet == null || archive == null) return const ArchiveMissingPage();
final cardCount = ref.watch(cardsOfPetProvider(pet.id)).length;
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(
title: pet.name,
subtitle: '추억 보관함',
titleLeading: const MemoryMark(size: 17),
onBack: () => context.pop(),
),
),
ScreenPadding(child: _MemorialHero(pet: pet, archive: archive)),
const DividerBand(),
ScreenPadding(child: _MemoryRecord(archive: archive)),
const DividerBand(top: 18),
const ScreenPadding(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '둘러보기'),
),
),
const SizedBox(height: 10),
ScreenPadding(
child: RupCard(
child: Column(
children: [
_BrowseRow(
first: true,
icon: const RupIcon(RupIconName.paw, size: 18, color: AppColors.memory),
title: '원래 정보',
sub: '기본·건강 정보를 다시 봐요.',
onTap: () => unawaited(context.push(AppRoutes.archivedPetInfo(pet.id))),
),
_BrowseRow(
icon: const RupGlyphIcon(RupGlyph.petCard, size: 19, color: AppColors.memory),
title: '펫 명함',
sub: cardCount > 0 ? '함께한 펫 명함 $cardCount개' : '만든 펫 명함이 없어요',
onTap: () => unawaited(context.push(AppRoutes.archivedPetCards(pet.id))),
),
_BrowseRow(
icon: const RupGlyphIcon(RupGlyph.diary, size: 17, color: AppColors.memory),
title: '다이어리',
sub: '함께한 날들을 다시 봐요.',
onTap: () => unawaited(context.push(AppRoutes.archivedPetDiaries(pet.id))),
),
],
),
),
),
const SizedBox(height: 44),
Center(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => unawaited(_restore(context, ref)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupIcon(RupIconName.repeat, size: 17, stroke: 2.2, color: AppColors.memory),
const SizedBox(width: 6),
Text(
'${pet.name} 다시 불러오기',
style: textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.memory,
height: _kNormal,
),
),
],
),
),
),
),
const SizedBox(height: AppSpacing.md),
],
);
}
}
/// 메모리얼 머리 — 바랜 사진 위에 이름과 함께한 기간
class _MemorialHero extends StatelessWidget {
const _MemorialHero({required this.pet, required this.archive});
final Pet pet;
final PetArchive archive;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final kind = pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}';
return Container(
constraints: const BoxConstraints(minHeight: _kHeroMinHeight),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(AppRadius.card), boxShadow: AppShadows.sm),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned.fill(child: ArchivedPhoto(pet: pet)),
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: _kHeroShade,
stops: _kHeroShadeStops,
),
),
),
),
Container(
constraints: const BoxConstraints(minHeight: _kHeroMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
alignment: Alignment.bottomLeft,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 25,
height: 1.1,
color: Colors.white,
shadows: _kHeroTextShadow,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
kind,
style: textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: .92),
height: _kNormal,
shadows: const [Shadow(color: Color(0x66000000), blurRadius: 8, offset: Offset(0, 1))],
),
),
const SizedBox(height: 9),
_GlassChip(text: '${archive.spanLabel} 함께했어요'),
],
),
),
],
),
);
}
}
/// 사진 위의 반투명 알약 (시안 backdrop-filter blur 6)
class _GlassChip extends StatelessWidget {
const _GlassChip({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.pill),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .18),
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: Colors.white.withValues(alpha: .32)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const MemoryMark(size: 14, color: Colors.white),
const SizedBox(width: 6),
Text(
text,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: Colors.white,
height: _kNormal,
),
),
],
),
),
),
);
}
}
/// 추억 기록 — 함께한 기간 · 보호자 메모 (읽기 전용)
class _MemoryRecord extends StatelessWidget {
const _MemoryRecord({required this.archive});
final PetArchive archive;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final value = textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w700,
color: AppColors.ink,
height: _kNormal,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SectionTitle(title: '추억 기록'),
const SizedBox(height: AppSpacing.xs),
_InfoRow(
first: true,
icon: RupIconName.calendar,
label: '함께한 기간',
child: Text.rich(
TextSpan(children: [
TextSpan(text: '${formatYmd(archive.from)} '),
TextSpan(
text: '~',
style: value.copyWith(fontWeight: FontWeight.w600, color: AppColors.muted),
),
TextSpan(text: ' ${formatYmd(archive.to)}'),
]),
style: value,
),
),
if (archive.memo case final memo?)
_InfoRow(
icon: RupIconName.text,
label: '보호자 메모',
child: Text(
memo,
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w500, color: AppColors.ink2, height: 1.6),
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.icon, required this.label, required this.child, this.first = false});
final RupIconName icon;
final String label;
final Widget child;
final bool first;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 13),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
// 시안 color-mix(tint, #fff 85%)
color: Color.lerp(AppColors.memory, Colors.white, .85),
borderRadius: BorderRadius.circular(11),
),
alignment: Alignment.center,
child: RupIcon(icon, size: 18, stroke: 2, color: AppColors.memory),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: AppColors.muted,
letterSpacing: .115,
height: _kNormal,
),
),
const SizedBox(height: 3),
child,
],
),
),
),
],
),
);
}
}
/// 둘러보기 한 줄 — 보라 타일 아이콘 + 제목 + 설명
class _BrowseRow extends StatelessWidget {
const _BrowseRow({
required this.icon,
required this.title,
required this.sub,
required this.onTap,
this.first = false,
});
final Widget icon;
final String title;
final String sub;
final VoidCallback onTap;
final bool first;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 15),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(color: AppColors.memory50, borderRadius: BorderRadius.circular(11)),
alignment: Alignment.center,
child: icon,
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: textTheme.titleSmall!.copyWith(fontSize: 15.5, height: _kNormal)),
const SizedBox(height: 1),
Text(sub, style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal)),
],
),
),
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
),
),
);
}
}
@@ -0,0 +1,302 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/archive/presentation/providers/archive_actions.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/shared/widgets/date_picker_sheet.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kNormal = AppTypography.normalHeight;
const double _kHeroHeight = 168;
const double _kMemoMinHeight = 84;
/// 바닥 버튼 아래 여백 (시안 padding-bottom 30 — 홈 인디케이터 자리)
const double _kBarBottom = 30;
/// 추억 보관함으로 옮기기 — 함께한 기간과 메모를 남기고 보관한다
class PetArchiveMovePage extends ConsumerStatefulWidget {
const PetArchiveMovePage({super.key, required this.petId});
final String petId;
@override
ConsumerState<PetArchiveMovePage> createState() => _PetArchiveMovePageState();
}
class _PetArchiveMovePageState extends ConsumerState<PetArchiveMovePage> {
final _memo = TextEditingController();
DateTime? _from;
late DateTime _to = dateOnly(DateTime.now());
@override
void dispose() {
_memo.dispose();
super.dispose();
}
void _alert(String text) => ref.read(toastProvider.notifier).show(text, icon: RupIconName.alert);
Future<void> _pickFrom(Pet pet, DateTime from) async {
final v = await showBirthDatePicker(context, initial: from, title: '${withAndParticle(pet.name)} 함께한 날');
if (v == null || !mounted) return;
if (v.isAfter(_to)) {
_alert('함께한 첫날은 마지막 날보다 늦을 수 없어요');
return;
}
setState(() => _from = v);
}
Future<void> _pickTo(Pet pet, DateTime from) async {
final v = await showBirthDatePicker(context, initial: _to, title: '${withObjectParticle(pet.name)} 떠나보낸 날');
if (v == null || !mounted) return;
if (v.isBefore(from)) {
_alert('마지막 날은 함께한 첫날보다 빠를 수 없어요');
return;
}
if (v.isAfter(dateOnly(DateTime.now()))) {
_alert('오늘 이후의 날짜는 고를 수 없어요');
return;
}
setState(() => _to = v);
}
Future<void> _submit(Pet pet, DateTime from) async {
final memo = _memo.text.trim();
await ref.read(archiveActionsProvider).archive(
pet.id,
PetArchive(from: from, to: _to, archivedAt: DateTime.now(), memo: memo.isEmpty ? null : memo),
);
if (mounted) context.go(AppRoutes.myPage);
}
@override
Widget build(BuildContext context) {
final pet = ref.watch(petByIdProvider(widget.petId));
// 이미 보관했거나 지워진 아이 — 옮긴 직후 한 프레임 등
if (pet == null || pet.isArchived) return const Scaffold(body: SizedBox.shrink());
final from = _from ?? pet.birthDate;
final textTheme = Theme.of(context).textTheme;
final label = textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.ink2,
height: _kNormal,
);
return Scaffold(
body: Column(
children: [
Expanded(
child: SafeArea(
bottom: false,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.xs, AppSpacing.xl, AppSpacing.xl),
children: [
SubPageHeader(
title: '${withObjectParticle(pet.name)} 추억 보관함으로',
subtitle: '함께한 시간을 소중히 보관해 둘게요',
onBack: () => context.pop(),
),
Container(
height: _kHeroHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: AppShadows.sm,
),
clipBehavior: Clip.antiAlias,
child: ArchivedPhoto(pet: pet),
),
// 시안: 사진 아래 18 + 제목 위 24 (세로 flex 라 겹치지 않는다)
const SizedBox(height: 42),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text('${withAndParticle(pet.name)} 함께한 기간을 알려주세요', style: label),
),
const SizedBox(height: 11),
Row(
children: [
Expanded(
child: _DateButton(
label: '함께한 첫날',
value: from,
onTap: () => unawaited(_pickFrom(pet, from)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 9),
child: Text(
'~',
style: textTheme.bodyMedium!.copyWith(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
),
Expanded(
child: _DateButton(label: '마지막 날', value: _to, onTap: () => unawaited(_pickTo(pet, from))),
),
],
),
const SizedBox(height: 9),
Padding(
padding: const EdgeInsets.only(left: 2),
child: Text(
'${withAndParticle(pet.name)} ${togetherSpan(from, _to)} 함께했어요',
style: textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.memory,
height: _kNormal,
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text.rich(
TextSpan(children: [
TextSpan(text: '${withAndParticle(pet.name)} 함께한 시간을 남겨주세요 '),
TextSpan(
text: '(선택)',
style: label.copyWith(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.muted),
),
]),
style: label,
),
),
const SizedBox(height: 9),
RupTextField(
controller: _memo,
multiline: true,
minHeight: _kMemoMinHeight,
hintText: '고마웠던 순간, 기억하고 싶은 이야기를 자유롭게 적어주세요',
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.check, size: 15, stroke: 2.2, color: AppColors.accent),
const SizedBox(width: 6),
Flexible(
child: Text(
'보관 후에도 추억 보관함에서 언제든 다시 볼 수 있어요',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.5),
),
),
],
),
],
),
),
),
_BottomBar(label: '추억 보관함으로 옮기기', onTap: () => unawaited(_submit(pet, from))),
],
),
);
}
}
/// 날짜 칸 — 작은 제목 + 달력 아이콘 + 날짜
class _DateButton extends StatelessWidget {
const _DateButton({required this.label, required this.value, required this.onTap});
final String label;
final DateTime value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Pressable(
onTap: onTap,
semanticLabel: '$label ${formatYmd(value)}',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.bodySmall!.copyWith(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
const SizedBox(height: 2),
Row(
children: [
const RupIcon(RupIconName.calendar, size: 15, stroke: 2, color: AppColors.memory),
const SizedBox(width: 6),
Flexible(
child: Text(
formatYmd(value),
maxLines: 1,
style: textTheme.bodyMedium!.copyWith(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
],
),
),
);
}
}
/// 화면 바닥에 붙은 주황 버튼 — 모서리 없이 가로 끝까지
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final inset = MediaQuery.paddingOf(context).bottom;
return Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
color: AppColors.primary,
padding: EdgeInsets.only(top: AppSpacing.lg, bottom: inset > _kBarBottom ? inset : _kBarBottom),
alignment: Alignment.center,
child: Text(label, style: AppTypography.button(16, color: Colors.white)),
),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 추억 보관함 — 옮기기 / 다시 불러오기
class ArchiveActions {
ArchiveActions(this._ref);
final Ref _ref;
/// 아이를 보관함으로 옮긴다. 일정은 지우고(여러 아이 일정이면 그 아이만 뗀다),
/// 다이어리·펫 명함·정보는 그대로 두어 보관함에서 다시 볼 수 있게 한다.
Future<void> archive(String petId, PetArchive archive) async {
await _ref.read(petListProvider.notifier).archivePet(petId, archive);
await _ref.read(scheduleProvider.notifier).dropTarget(petId);
_ref.read(toastProvider.notifier).show('추억보관함에 보관했어요.', icon: RupIconName.memory);
}
/// 보관함에서 다시 불러온다 — 지운 일정은 돌아오지 않는다
Future<void> restore(String petId) async {
await _ref.read(petListProvider.notifier).restorePet(petId);
_ref.read(toastProvider.notifier).show('다시 불러왔어요');
}
}
final archiveActionsProvider = Provider<ArchiveActions>(ArchiveActions.new);
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/shared/widgets/grayscale.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이 사진의 빛바램 (시안 grayscale(.3))
const double kArchiveFade = .3;
/// 사진이 없을 때 칸 바탕 (시안 rgba(0,0,0,.04))
const Color _kEmptyPhotoTint = Color(0x0A000000);
/// 추억 보관함 표시 — 보라 구름
class MemoryMark extends StatelessWidget {
const MemoryMark({super.key, required this.size, this.color = AppColors.memory});
final double size;
final Color color;
@override
Widget build(BuildContext context) => RupGlyphIcon(RupGlyph.memory, size: size, color: color);
}
/// 보관된 아이의 하위 화면 머리 — 부제에 "콩이 · 추억 보관함"
class ArchiveSubHeader extends StatelessWidget {
const ArchiveSubHeader({super.key, required this.title, required this.petName, this.onBack, this.bottomGap = 18});
final String title;
final String petName;
/// 없으면 한 단계 뒤로
final VoidCallback? onBack;
final double bottomGap;
@override
Widget build(BuildContext context) {
return SubPageHeader(
title: title,
subtitle: '$petName · 추억 보관함',
subtitleLeading: const MemoryMark(size: 12),
onBack: onBack ?? () => context.pop(),
bottomGap: bottomGap,
);
}
}
/// 보관된 아이 사진을 가득 채워 빛바래게 — 사진이 없으면 기본 그림
class ArchivedPhoto extends StatelessWidget {
const ArchivedPhoto({super.key, required this.pet});
final Pet pet;
@override
Widget build(BuildContext context) {
final photo = pet.photoPath;
return Grayscale(
amount: kArchiveFade,
child: photo == null
? ColoredBox(
// 시안 image-slot: 화면 바탕 위에 rgba(0,0,0,.04)
color: Color.alphaBlend(_kEmptyPhotoTint, AppColors.bg),
child: SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover),
)
: PetPhotoImage(path: photo),
);
}
}
/// 보관함에서 찾는 아이가 없을 때 — 다시 불러왔거나 잘못된 주소
class ArchiveMissingPage extends StatelessWidget {
const ArchiveMissingPage({super.key});
@override
Widget build(BuildContext context) {
return TabSubPage(
children: [
ScreenPadding(child: SubPageHeader(title: '추억 보관함', onBack: () => context.go(AppRoutes.archive))),
const EmptyMessage('보관된 아이가 없어요', padding: 60),
],
);
}
}
@@ -51,6 +51,10 @@ class Diary {
/// 누구의 다이어리인지 — 반려동물 id 또는 나(self)
final List<String> targetIds;
/// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다
bool isArchivedWith(Set<String> archivedPetIds) =>
targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains);
final Color ribbon;
final Color tint;
final String? coverPath;
@@ -11,10 +11,24 @@ class DiaryBook {
final List<DiaryPost> posts;
final List<DiaryComment> comments;
bool get atMax => diaries.length >= kMaxDiaries;
/// 책장에 꽂힌 다이어리 — 추억 보관함의 아이만 담은 다이어리는 빠진다
List<Diary> shelf([Set<String> archivedPetIds = const {}]) =>
diaries.where((d) => !d.isArchivedWith(archivedPetIds)).toList();
/// 책장이 가득 찼는지 — 보관함으로 간 다이어리는 세지 않는다
bool atMax([Set<String> archivedPetIds = const {}]) => shelf(archivedPetIds).length >= kMaxDiaries;
Diary? diary(String id) => diaries.where((d) => d.id == id).firstOrNull;
/// 한 아이(또는 나)가 담긴 다이어리 — 추억 보관함에서 함께한 다이어리를 볼 때
List<Diary> diariesOf(String targetId) => diaries.where((d) => d.targetIds.contains(targetId)).toList();
/// 한 아이가 담긴 다이어리들의 기록 수
int postCountOf(String targetId) {
final ids = {for (final d in diariesOf(targetId)) d.id};
return posts.where((p) => ids.contains(p.diaryId)).length;
}
/// 새 기록이 위로 — 날짜, 같은 날이면 쓴 순서
static int _newestFirst(DiaryPost a, DiaryPost b) {
final byDate = b.date.compareTo(a.date);
@@ -32,8 +46,11 @@ class DiaryBook {
return posts.any((p) => p.diaryId == diaryId && p.authorId == authorId && p.date == d);
}
/// 모든 다이어리의 최근 기록 — 홈
List<DiaryPost> latest(int limit) => (List.of(posts)..sort(_newestFirst)).take(limit).toList();
/// 최근 기록 — 홈. [diaryIds] 를 주면 그 다이어리들의 기록만
List<DiaryPost> latest(int limit, {Set<String>? diaryIds}) {
final shown = diaryIds == null ? posts : posts.where((p) => diaryIds.contains(p.diaryId));
return (List.of(shown)..sort(_newestFirst)).take(limit).toList();
}
List<DiaryComment> commentsOf(String postId) =>
comments.where((c) => c.postId == postId).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
@@ -49,12 +49,21 @@ enum FeedMode {
FeedMode get next => FeedMode.values[(index + 1) % FeedMode.values.length];
}
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (추억 보관함에서 열 때)
const double _kTabBottomGap = 30;
/// 추억 보관함 안에 끼워 열 때 머리가 위아래로 2씩 좁다 (시안 embedded margin 2px 0 16px)
const double _kEmbeddedTrim = 2;
/// 다이어리 안 — 기록 피드(모두·사진·글) / 달력 보기, 검색, 오늘 쓰기
class DiaryPage extends ConsumerStatefulWidget {
const DiaryPage({super.key, required this.diaryId});
const DiaryPage({super.key, required this.diaryId, this.archived = false});
final String diaryId;
/// 추억 보관함에서 다시 보는 다이어리 — 쓰기·설정 없이 읽기만
final bool archived;
@override
ConsumerState<DiaryPage> createState() => _DiaryPageState();
}
@@ -99,6 +108,7 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
final filtered = q.isEmpty ? posts : posts.where((p) => p.text.toLowerCase().contains(q)).toList();
final wroteToday = book.wroteOn(diary.id, today);
final safeBottom = MediaQuery.paddingOf(context).bottom;
final archived = widget.archived;
return Scaffold(
body: Stack(
@@ -107,18 +117,27 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
bottom: false,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.xs, AppSpacing.xl, safeBottom + _kFabGap + _kFabSize),
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs - (archived ? _kEmbeddedTrim : 0),
AppSpacing.xl,
safeBottom + (archived ? _kTabBottomGap : _kFabGap + _kFabSize),
),
children: [
SubPageHeader(
title: diary.name,
subtitle: '기록 ${posts.length}개',
subtitleLeading: archived ? const RupGlyphIcon(RupGlyph.memory, size: 12, color: AppColors.memory) : null,
displayFont: true,
bottomGap: archived ? 18 - _kEmbeddedTrim : 18,
onBack: () => context.pop(),
trailing: _HeaderIconButton(
label: '다이어리 설정',
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
),
trailing: archived
? null
: _HeaderIconButton(
label: '다이어리 설정',
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
),
),
Row(
children: [
@@ -143,20 +162,22 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
if (_calendar)
..._calendarBody(diary, filtered, today)
else
..._feedBody(filtered, searching: q.isNotEmpty, wroteToday: wroteToday),
..._feedBody(filtered, searching: q.isNotEmpty, wroteToday: wroteToday || archived),
],
),
),
Positioned(
right: AppSpacing.xl,
bottom: safeBottom + _kFabGap,
child: _WriteFab(done: wroteToday, onTap: () => _write(wroteToday)),
),
if (!archived)
Positioned(
right: AppSpacing.xl,
bottom: safeBottom + _kFabGap,
child: _WriteFab(done: wroteToday, onTap: () => _write(wroteToday)),
),
],
),
);
}
/// [wroteToday] 이면 "첫 기록 남기기" 를 숨긴다 (보관함의 다이어리도)
List<Widget> _feedBody(List<DiaryPost> posts, {required bool searching, required bool wroteToday}) {
Widget menu(DiaryPost p) => _PostView(post: p, mode: _mode);
final shown = _mode == FeedMode.image ? posts.where((p) => p.hasPhoto).toList() : posts;
@@ -7,6 +7,7 @@ import 'package:tma_app/features/diary/data/diary_repository.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/presentation/providers/pet_providers.dart';
/// 다이어리 · 기록 · 댓글
class DiaryNotifier extends AsyncNotifier<DiaryBook> {
@@ -32,7 +33,7 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
required Color ribbon,
String? coverPath,
}) async {
if (_book.atMax) return null;
if (_book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {})) return null;
final diary = Diary(
id: _newId('d'),
name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(),
@@ -82,3 +83,12 @@ final diaryProvider = AsyncNotifierProvider<DiaryNotifier, DiaryBook>(DiaryNotif
/// 불러오기 전에도 쓸 수 있는 다이어리 (빈 목록으로 시작)
final diaryBookProvider = Provider<DiaryBook>((ref) => ref.watch(diaryProvider).value ?? DiaryBook.empty);
/// 책장의 다이어리 — 추억 보관함의 아이만 담은 다이어리는 빠진다
final shelfDiariesProvider = Provider<List<Diary>>((ref) => watchShelf(ref));
/// 책장의 다이어리를 알림기에서 바로 계산한다 — 다른 파생 프로바이더를 거치지 않게 ([petListProvider] 주석 참고)
List<Diary> watchShelf(Ref ref) {
final book = ref.watch(diaryProvider).value ?? DiaryBook.empty;
return book.shelf(ref.watch(petListProvider).value?.archivedIds ?? const {});
}
@@ -41,12 +41,21 @@ const Color _kEmptyCoverShade = Color(0x0A000000);
/// 다이어리 표지 — 사진(없으면 고유색), 아이들, 책갈피 리본, 이름·기록 수
class DiaryCoverTile extends StatelessWidget {
const DiaryCoverTile({super.key, required this.diary, required this.postCount, required this.onTap});
const DiaryCoverTile({
super.key,
required this.diary,
required this.postCount,
required this.onTap,
this.showTargets = true,
});
final Diary diary;
final int postCount;
final VoidCallback onTap;
/// 왼쪽 위 아이들 — 추억 보관함의 다이어리 목록은 싣지 않는다
final bool showTargets;
@override
Widget build(BuildContext context) {
final cover = diary.coverPath;
@@ -83,7 +92,7 @@ class DiaryCoverTile extends StatelessWidget {
),
),
Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon)),
Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)),
if (showTargets) Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)),
Positioned(
left: 14,
right: 12,
@@ -219,7 +219,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final names = ref.watch(targetNamesProvider);
const gap = SizedBox(height: AppSpacing.lg);
@@ -37,6 +37,8 @@ class DiaryShelf extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final book = ref.watch(diaryBookProvider);
final diaries = ref.watch(shelfDiariesProvider);
final atMax = diaries.length >= kMaxDiaries;
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -47,12 +49,12 @@ class DiaryShelf extends ConsumerWidget {
children: [
const Expanded(child: SectionTitle(title: '나의 다이어리')),
Text(
'${book.diaries.length}/$kMaxDiaries',
'${diaries.length}/$kMaxDiaries',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
height: AppTypography.normalHeight,
color: book.atMax ? AppColors.primary600 : AppColors.muted,
color: atMax ? AppColors.primary600 : AppColors.muted,
),
),
],
@@ -61,13 +63,13 @@ class DiaryShelf extends ConsumerWidget {
const SizedBox(height: AppSpacing.md),
DiaryGrid(
children: [
for (final d in book.diaries)
for (final d in diaries)
DiaryCoverTile(
diary: d,
postCount: book.countOf(d.id),
onTap: () => unawaited(context.push(AppRoutes.diary(d.id))),
),
if (!book.atMax) AddDiaryTile(onTap: () => unawaited(showAddDiarySheet(context, ref))),
if (!atMax) AddDiaryTile(onTap: () => unawaited(showAddDiarySheet(context, ref))),
],
),
],
@@ -83,7 +85,9 @@ class HomeDiaryGrid extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final book = ref.watch(diaryBookProvider);
final names = ref.watch(targetNamesProvider);
final latest = book.latest(_kHomeDiaryCount);
// 추억 보관함으로 간 다이어리의 기록은 홈에 올리지 않는다
final shelf = {for (final d in ref.watch(shelfDiariesProvider)) d.id};
final latest = book.latest(_kHomeDiaryCount, diaryIds: shelf);
if (latest.isEmpty) return const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요'));
final today = dateOnly(DateTime.now());
@@ -34,7 +34,7 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value;
final pets = ref.watch(petListProvider).value?.active;
// 불러오는 동안은 자리만 잡는다 — "없어요" 가 스치지 않게
if (pets == null) return const SizedBox(height: _kLoadingHeight);
if (pets.isEmpty) {
@@ -111,6 +111,8 @@ class MyPage extends ConsumerWidget {
],
),
),
const SettingsGroupLabel('추억 보관함'),
RupCard(child: _ArchiveRow(onTap: () => context.go(AppRoutes.archive))),
const SizedBox(height: 56),
const Center(child: RupLogo(height: 22)),
const SizedBox(height: 2),
@@ -161,7 +163,7 @@ class _ProfileCard extends ConsumerWidget {
final nickname = ref.watch(userNicknameProvider);
final photo = ref.watch(profileProvider).photoPath;
final provider = ref.watch(signedInProviderProvider);
final petCount = (ref.watch(petListProvider).value ?? const <Pet>[]).length;
final petCount = ((ref.watch(petListProvider).value ?? const <Pet>[]).active).length;
final cardCount = ref.watch(myCardsProvider).length;
final textTheme = Theme.of(context).textTheme;
@@ -235,6 +237,60 @@ class _ProfileCard extends ConsumerWidget {
}
}
/// 추억 보관함 — 보관된 아이 바로가기
class _ArchiveRow extends ConsumerWidget {
const _ArchiveRow({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = (ref.watch(petListProvider).value ?? const <Pet>[]).archived.length;
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: AppColors.memory50, borderRadius: BorderRadius.circular(10)),
alignment: Alignment.center,
child: const RupGlyphIcon(RupGlyph.memory, size: 18, color: AppColors.memory),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('보관된 아이', style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal)),
const SizedBox(height: 1),
Text(
'함께한 다이어리와 펫 명함을 보관해요',
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal),
),
],
),
),
if (count > 0) ...[
Text('$count마리', style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal)),
const SizedBox(width: 13),
],
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
),
),
);
}
}
class _StatTile extends StatelessWidget {
const _StatTile({required this.art, required this.title, required this.value, required this.onTap});
@@ -29,7 +29,7 @@ class MyPetsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final today = DateTime.now();
return TabSubPage(
@@ -35,8 +35,7 @@ final noticeSeenProvider = NotifierProvider<NoticeSeenNotifier, DateTime?>(Notic
/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다.
final noticesProvider = Provider<List<AppNotice>>((ref) => _watchNotices(ref));
/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 이으면 Riverpod 3.3 에서
/// 가려졌던 화면이 다시 보일 때 빌드 도중 재계산이 번져 markNeedsBuild 오류가 난다
/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고)
List<AppNotice> _watchNotices(Ref ref) {
final now = ref.watch(clockProvider)();
// 저녁 8시·자정에 다시 계산한다 — 앱을 켜 둔 채 시각이 지나도 기록 알림이 맞게 나타나고 사라진다
@@ -54,7 +53,8 @@ List<AppNotice> _watchNotices(Ref ref) {
final notices = <AppNotice>[];
if (now.hour >= kDiaryReminderHour) {
final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour);
for (final d in book.diaries) {
// 추억 보관함으로 간 다이어리는 알리지 않는다
for (final d in watchShelf(ref)) {
if (!d.notifies(DiaryNotice.reminder) || book.wroteOn(d.id, now)) continue;
notices.add(AppNotice(
id: 'reminder-${d.id}-${toIsoDate(now)}',
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 반려동물 저장소 계약 — 백엔드 API(/api/pets)가 생기면 Dio 구현으로 바꾼다.
abstract interface class PetRepository {
@@ -15,6 +16,12 @@ abstract interface class PetRepository {
Future<Pet> update(String id, PetInput input);
Future<void> delete(String id);
/// 추억 보관함으로 옮긴다 — 아이와 정보는 그대로 두고 보관 기록을 붙인다
Future<Pet> archive(String id, PetArchive archive);
/// 보관함에서 다시 불러온다
Future<Pet> restore(String id);
}
const String _kPetsKey = 'pets.v1';
@@ -55,9 +62,22 @@ class LocalPetRepository implements PetRepository {
@override
Future<Pet> update(String id, PetInput input) async {
final pets = _read();
// 고유색은 앱이 정한 값이라 수정해도 유지한다
final tint = pets.firstWhere((p) => p.id == id).tint;
final pet = Pet.fromInput(id, input, tint: tint);
// 고유색·보관 기록은 앱이 정한 값이라 수정해도 유지한다
final old = pets.firstWhere((p) => p.id == id);
final pet = Pet.fromInput(id, input, tint: old.tint, archive: old.archive);
await _write([for (final p in pets) p.id == id ? pet : p]);
return pet;
}
@override
Future<Pet> archive(String id, PetArchive archive) => _setArchive(id, archive);
@override
Future<Pet> restore(String id) => _setArchive(id, null);
Future<Pet> _setArchive(String id, PetArchive? archive) async {
final pets = _read();
final pet = pets.firstWhere((p) => p.id == id).withArchive(archive);
await _write([for (final p in pets) p.id == id ? pet : p]);
return pet;
}
+18 -2
View File
@@ -2,6 +2,7 @@ import 'dart:ui' show Color;
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 아이별 고유색 — 등록 순서대로 돌아가며 배정한다 (시안 buildPet 의 tints)
const List<Color> kPetTints = [
@@ -105,12 +106,14 @@ class Pet extends PetInput {
super.allergies,
super.conditions,
super.watchConditions,
this.archive,
});
/// [tint] 는 앱이 정한다 — 새로 만들 때 배정하고, 수정할 때는 기존 색을 넘긴다
factory Pet.fromInput(String id, PetInput input, {Color tint = const Color(0xFFFF9B6A)}) => Pet(
/// [tint]·[archive] 는 앱이 정한다 — 새로 만들 때 배정하고, 수정할 때는 기존 값을 넘긴다
factory Pet.fromInput(String id, PetInput input, {Color tint = const Color(0xFFFF9B6A), PetArchive? archive}) => Pet(
id: id,
tint: tint,
archive: archive,
name: input.name,
species: input.species,
breed: input.breed,
@@ -134,6 +137,14 @@ class Pet extends PetInput {
/// 목록 왼쪽 띠·칩 등에 쓰는 아이의 고유색
final Color tint;
/// 추억 보관함으로 옮겼으면 그 기록 — 함께 지내는 아이는 null
final PetArchive? archive;
bool get isArchived => archive != null;
/// 보관 정보만 바꾼 아이 — 보관하기(값) / 다시 불러오기(null)
Pet withArchive(PetArchive? value) => Pet.fromInput(id, this, tint: tint, archive: value);
/// 사진만 바꾼 입력값 — 명함 편집기의 카메라 버튼
PetInput withPhoto(String path) => PetInput(
name: name,
@@ -181,6 +192,7 @@ class Pet extends PetInput {
'allergies': allergies,
'conditions': conditions,
'watchConditions': watchConditions,
if (archive case final a?) 'archive': a.toJson(),
};
factory Pet.fromJson(Map<String, Object?> json) => Pet(
@@ -202,6 +214,10 @@ class Pet extends PetInput {
allergies: _stringList(json['allergies']),
conditions: _stringList(json['conditions']),
watchConditions: _stringList(json['watchConditions']),
archive: switch (json['archive']) {
final Map<String, Object?> a => PetArchive.fromJson(a),
_ => null,
},
);
}
@@ -0,0 +1,46 @@
import 'package:tma_app/core/utils/date_format.dart';
/// 추억 보관함으로 옮긴 아이의 기록 — 함께한 기간과 보호자가 남긴 이야기
class PetArchive {
const PetArchive({required this.from, required this.to, required this.archivedAt, this.memo});
/// 함께한 첫날
final DateTime from;
/// 마지막 날
final DateTime to;
/// 보관한 때 — 보관함 목록 순서에 쓴다
final DateTime archivedAt;
/// 함께한 시간에 대한 메모 (선택)
final String? memo;
/// "8년 3개월"
String get spanLabel => togetherSpan(from, to);
Map<String, Object?> toJson() => {
'from': toIsoDate(from),
'to': toIsoDate(to),
'archivedAt': archivedAt.toIso8601String(),
'memo': memo,
};
factory PetArchive.fromJson(Map<String, Object?> json) => PetArchive(
from: parseIsoDate(json['from']! as String),
to: parseIsoDate(json['to']! as String),
archivedAt: DateTime.parse(json['archivedAt']! as String),
memo: json['memo'] as String?,
);
}
/// 함께한 기간 — "8년 3개월" · "2년" · "5개월". 한 달이 안 되면 "한 달"
String togetherSpan(DateTime from, DateTime to) {
var months = (to.year - from.year) * 12 + (to.month - from.month);
if (to.day < from.day) months -= 1;
if (months < 0) months = 0;
final years = months ~/ 12;
final rest = months % 12;
final parts = [if (years > 0) '$years년', if (rest > 0) '$rest개월'];
return parts.isEmpty ? '한 달' : parts.join(' ');
}
@@ -9,16 +9,17 @@ import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.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/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_info_sections.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart' show kUnregistered;
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
@@ -28,11 +29,6 @@ const double _kPhotoRadius = 20;
const Color _kPhotoBorder = Color(0xFFC8C8C8);
const double _kNormal = AppTypography.normalHeight;
/// 건강 정보 태그 색 (시안 TagField tone)
const Color _kAllergyTone = Color(0xFFE0534F);
const Color _kConditionTone = Color(0xFFC77A1F);
const Color _kWatchTone = Color(0xFF8E5BD0);
/// 반려동물 정보 — 기본·건강 정보를 읽기 전용으로 보여 주고, 연필을 누르면 수정
class PetInfoPage extends ConsumerWidget {
const PetInfoPage({super.key, required this.petId});
@@ -55,7 +51,6 @@ class PetInfoPage extends ConsumerWidget {
}
final cardCount = ref.watch(myCardsProvider).where((c) => c.petId == pet.id).length;
final gender = '${pet.gender.label} · ${pet.neutered ? '중성화 완료' : '미중성화'}';
return TabSubPage(
children: [
@@ -84,35 +79,66 @@ class PetInfoPage extends ConsumerWidget {
),
),
),
const DividerBand(),
_Section(
title: '기본 정보',
children: [
_Field(label: '나이', value: petAgeLabel(pet.birthDate, DateTime.now())),
_Field(label: '생년월일', value: formatYmd(pet.birthDate)),
_Field(label: '성별', value: gender),
_Field(label: '몸무게', value: pet.weightLabel),
_Field(label: '혈액형', value: pet.bloodType ?? kUnregistered),
_Field(label: '마이크로칩', value: pet.microchip ?? kUnregistered),
],
),
const DividerBand(),
_Section(
title: '건강 정보',
children: [
_TagField(label: '알레르기', items: pet.allergies, tone: _kAllergyTone),
_TagField(label: '보유 질환', items: pet.conditions, tone: _kConditionTone),
_TagField(label: '염려 질환', items: pet.watchConditions, tone: _kWatchTone),
_Field(label: '담당 병원', value: pet.vet ?? kUnregistered),
],
),
if (pet.memo case final memo?) ...[
const DividerBand(),
_MemoSection(memo: memo),
],
...petInfoSections(pet),
const SizedBox(height: 48),
_ArchiveLink(onTap: () => unawaited(_confirmArchive(context, pet))),
const SizedBox(height: AppSpacing.sm),
],
);
}
/// 보관 안내 — 보관하기를 누르면 기간·메모를 남기는 화면으로
static Future<void> _confirmArchive(BuildContext context, Pet pet) async {
final ok = await showRupConfirm(
context,
title: '${withObjectParticle(pet.name)} 추억 보관함으로',
message: '아이와 아이에 대한 정보를 추억 보관함에 보관할까요?\n'
'등록된 일정은 모두 삭제되며, 다이어리와 기록은 그대로 보관돼요.\n'
'일정 외의 정보는 언제든지 다시 복구할 수 있어요.',
confirmLabel: '보관하기',
layout: RupConfirmLayout.row,
glyph: RupGlyph.memory,
iconColor: AppColors.memory,
iconBackground: AppColors.memory50,
confirmVariant: RupButtonVariant.memory,
);
if (ok && context.mounted) unawaited(context.push(AppRoutes.petArchive(pet.id)));
}
}
/// "추억 보관함으로" 글자 버튼
class _ArchiveLink extends StatelessWidget {
const _ArchiveLink({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Center(
child: Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupGlyphIcon(RupGlyph.memory, size: 17, color: AppColors.memory),
const SizedBox(width: 6),
Text(
'추억 보관함으로',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.memory,
height: _kNormal,
),
),
],
),
),
),
);
}
}
/// 사진 · 이름 · 종 · 명함 바로가기
@@ -200,164 +226,3 @@ class _IdentityCard extends StatelessWidget {
}
}
/// 섹션 제목 + 항목 카드 (항목 사이에 가는 줄)
class _Section extends StatelessWidget {
const _Section({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: title),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.fromLTRB(18, 2, 18, 18),
child: Column(
children: [
for (final (i, child) in children.indexed)
// Container 는 테두리 두께만큼 자리를 잡는다 (시안 border-top 1px 도 줄 높이에 더해진다)
Container(
decoration: BoxDecoration(
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: child,
),
],
),
),
],
),
);
}
}
class _Field extends StatelessWidget {
const _Field({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(label, style: _labelStyle(textTheme)),
const SizedBox(width: 14),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
);
}
}
TextStyle _labelStyle(TextTheme textTheme) =>
textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w600, color: AppColors.muted, height: _kNormal);
/// 태그 목록 항목 — 비어 있으면 "없음"
class _TagField extends StatelessWidget {
const _TagField({required this.label, required this.items, required this.tone});
final String label;
final List<String> items;
final Color tone;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 3), child: Text(label, style: _labelStyle(textTheme))),
const SizedBox(width: 14),
Expanded(
child: items.isEmpty
? Text(
'없음',
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal),
)
: Wrap(
alignment: WrapAlignment.end,
spacing: 5,
runSpacing: AppSpacing.xs,
children: [
for (final item in items)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: AppSpacing.xs),
decoration: BoxDecoration(
color: tone.withValues(alpha: .1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item,
style: textTheme.bodySmall!.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: tone,
height: _kNormal,
),
),
),
],
),
),
],
),
);
}
}
class _MemoSection extends StatelessWidget {
const _MemoSection({required this.memo});
final String memo;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '보호자 메모'),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.all(18),
child: Text(
memo,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.6, color: AppColors.ink2),
),
),
],
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.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';
/// 등록된 반려동물 목록
class PetListNotifier extends AsyncNotifier<List<Pet>> {
@@ -29,10 +30,38 @@ class PetListNotifier extends AsyncNotifier<List<Pet>> {
state = AsyncData([...?state.value?.where((p) => p.id != id)]);
}
}
Future<void> archivePet(String id, PetArchive archive) async => _replace(await _repo.archive(id, archive));
Future<void> restorePet(String id) async => _replace(await _repo.restore(id));
void _replace(Pet pet) {
if (!ref.mounted) return;
state = AsyncData([for (final p in state.value ?? const <Pet>[]) p.id == pet.id ? pet : p]);
}
}
/// 등록한 모든 아이 — 추억 보관함의 아이까지. 화면에서는 [PetListX.active] 로 걸러 쓴다.
///
/// ⚠️ 파생 프로바이더는 알림기(AsyncNotifier 등)만 watch 하고, 다른 파생 프로바이더를 watch 하지 않는다.
/// 가려졌던 화면이 다시 보일 때 Riverpod 3.3 은 빌드 도중에 멈춰 둔 구독을 풀면서 파생값을 다시 계산하는데,
/// 파생값이 다른 파생 프로바이더에 알리면 markNeedsBuild 오류가 난다 (Riverpod 3.4 에서 고쳐졌지만 Dart 3.12 필요).
/// 걸러 쓰는 값은 목록 확장([PetListX])이나 순수 함수로 나눠 각 프로바이더가 직접 계산한다.
final petListProvider = AsyncNotifierProvider<PetListNotifier, List<Pet>>(PetListNotifier.new);
/// 함께 지내는 아이 / 추억 보관함의 아이로 가르기
extension PetListX on List<Pet> {
/// 함께 지내는 아이 — 추억 보관함으로 옮긴 아이는 빠진다
List<Pet> get active => where((p) => !p.isArchived).toList();
/// 추억 보관함의 아이 — 최근에 보관한 아이가 위로
List<Pet> get archived =>
where((p) => p.isArchived).toList()..sort((a, b) => b.archive!.archivedAt.compareTo(a.archive!.archivedAt));
/// 보관된 아이 id — 다이어리를 가를 때
Set<String> get archivedIds => {for (final p in this) if (p.isArchived) p.id};
}
/// 홈에서 고른 아이 id
class SelectedPetIdNotifier extends Notifier<String?> {
@override
@@ -45,14 +74,14 @@ final selectedPetIdProvider = NotifierProvider<SelectedPetIdNotifier, String?>(
SelectedPetIdNotifier.new,
);
/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌으면 첫 번째 아이
/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌거나 보관됐으면 첫 번째 아이
final selectedPetProvider = Provider<Pet?>((ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final id = ref.watch(selectedPetIdProvider);
return pets.where((p) => p.id == id).firstOrNull ?? pets.firstOrNull;
});
/// 반려동물 id 로 찾기 — 수정 화면 등
/// 반려동물 id 로 찾기 — 수정 화면 등 (보관된 아이도 찾는다)
final petByIdProvider = Provider.family<Pet?, String>((ref, id) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
return pets.where((p) => p.id == id).firstOrNull;
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.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_card/presentation/widgets/name_card.dart' show kUnregistered;
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
/// 건강 정보 태그 색 (시안 TagField tone)
const Color _kAllergyTone = Color(0xFFE0534F);
const Color _kConditionTone = Color(0xFFC77A1F);
const Color _kWatchTone = Color(0xFF8E5BD0);
/// 반려동물 정보의 기본·건강·메모 섹션 — 반려동물 정보와 추억 보관함의 "원래 정보"가 함께 쓴다.
/// 섹션마다 위에 구분 띠를 둔다.
List<Widget> petInfoSections(Pet pet) {
final gender = '${pet.gender.label} · ${pet.neutered ? '중성화 완료' : '미중성화'}';
return [
const DividerBand(),
_Section(
title: '기본 정보',
children: [
_Field(label: '나이', value: petAgeLabel(pet.birthDate, DateTime.now())),
_Field(label: '생년월일', value: formatYmd(pet.birthDate)),
_Field(label: '성별', value: gender),
_Field(label: '몸무게', value: pet.weightLabel),
_Field(label: '혈액형', value: pet.bloodType ?? kUnregistered),
_Field(label: '마이크로칩', value: pet.microchip ?? kUnregistered),
],
),
const DividerBand(),
_Section(
title: '건강 정보',
children: [
_TagField(label: '알레르기', items: pet.allergies, tone: _kAllergyTone),
_TagField(label: '보유 질환', items: pet.conditions, tone: _kConditionTone),
_TagField(label: '염려 질환', items: pet.watchConditions, tone: _kWatchTone),
_Field(label: '담당 병원', value: pet.vet ?? kUnregistered),
],
),
if (pet.memo case final memo?) ...[
const DividerBand(),
_MemoSection(memo: memo),
],
];
}
/// 섹션 제목 + 항목 카드 (항목 사이에 가는 줄)
class _Section extends StatelessWidget {
const _Section({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: title),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.fromLTRB(18, 2, 18, 18),
child: Column(
children: [
for (final (i, child) in children.indexed)
// Container 는 테두리 두께만큼 자리를 잡는다 (시안 border-top 1px 도 줄 높이에 더해진다)
Container(
decoration: BoxDecoration(
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: child,
),
],
),
),
],
),
);
}
}
class _Field extends StatelessWidget {
const _Field({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(label, style: _labelStyle(textTheme)),
const SizedBox(width: 14),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
);
}
}
TextStyle _labelStyle(TextTheme textTheme) =>
textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w600, color: AppColors.muted, height: _kNormal);
/// 태그 목록 항목 — 비어 있으면 "없음"
class _TagField extends StatelessWidget {
const _TagField({required this.label, required this.items, required this.tone});
final String label;
final List<String> items;
final Color tone;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 3), child: Text(label, style: _labelStyle(textTheme))),
const SizedBox(width: 14),
Expanded(
child: items.isEmpty
? Text(
'없음',
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal),
)
: Wrap(
alignment: WrapAlignment.end,
spacing: 5,
runSpacing: AppSpacing.xs,
children: [
for (final item in items)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: AppSpacing.xs),
decoration: BoxDecoration(
color: tone.withValues(alpha: .1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item,
style: textTheme.bodySmall!.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: tone,
height: _kNormal,
),
),
),
],
),
),
],
),
);
}
}
class _MemoSection extends StatelessWidget {
const _MemoSection({required this.memo});
final String memo;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '보호자 메모'),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.all(18),
child: Text(
memo,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.6, color: AppColors.ink2),
),
),
],
),
);
}
}
@@ -46,7 +46,7 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final cards = ref.watch(myCardsProvider);
final shown = _filterPetId == null ? cards : cards.where((c) => c.petId == _filterPetId).toList();
final filterPet = pets.where((p) => p.id == _filterPetId).firstOrNull;
@@ -43,7 +43,7 @@ class _PetCardsPageState extends ConsumerState<PetCardsPage> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final pet = pets.where((p) => p.id == _selectedPetId).firstOrNull ?? pets.firstOrNull;
Widget padded(Widget child) =>
@@ -77,14 +77,13 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
final petCardsProvider = AsyncNotifierProvider<PetCardNotifier, PetCardState>(PetCardNotifier.new);
/// 지금 등록된 아이들의 명함만 (지워진 아이의 명함은 숨긴다)
/// 함께 지내는 아이들의 명함만 (지워진 아이·추억 보관함의 아이 명함은 숨긴다)
final myCardsProvider = Provider<List<PetCard>>((ref) => _watchMyCards(ref));
/// 알림기에서 바로 계산한다 — 파생 프로바이더끼리 이으면 Riverpod 3.3 에서 가려졌던 화면이
/// 다시 보일 때 빌드 도중 재계산이 번져 markNeedsBuild 오류가 난다
/// 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 (petListProvider 주석 참고)
List<PetCard> _watchMyCards(Ref ref) {
final cards = ref.watch(petCardsProvider).value?.cards ?? const <PetCard>[];
final petIds = {for (final p in ref.watch(petListProvider).value ?? const <Pet>[]) p.id};
final petIds = {for (final p in (ref.watch(petListProvider).value ?? const <Pet>[]).active) p.id};
return cards.where((c) => petIds.contains(c.petId)).toList();
}
@@ -92,6 +91,12 @@ final cardByIdProvider = Provider.family<PetCard?, String>((ref, cardId) {
return _watchMyCards(ref).where((c) => c.id == cardId).firstOrNull;
});
/// 한 아이의 명함 — 추억 보관함에서 보관된 아이의 명함을 다시 볼 때
final cardsOfPetProvider = Provider.family<List<PetCard>, String>((ref, petId) {
final cards = ref.watch(petCardsProvider).value?.cards ?? const <PetCard>[];
return cards.where((c) => c.petId == petId).toList();
});
/// 아이의 대표 명함 — 지정한 것이 없으면 그 아이의 첫 명함
final representativeCardProvider = Provider.family<PetCard?, String>((ref, petId) {
final cards = _watchMyCards(ref).where((c) => c.petId == petId).toList();
@@ -60,7 +60,7 @@ class EventEditorPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 아이 목록·일정을 다 불러온 뒤에 연다 — 불러오기 전에 열려 기본값으로 채워지지 않게
final pets = ref.watch(petListProvider).value;
final pets = ref.watch(petListProvider).value?.active;
final schedule = ref.watch(scheduleProvider).value;
if (pets == null || schedule == null) return const Scaffold(body: SizedBox.shrink());