diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 916815a..300f65c 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -4,6 +4,12 @@ import 'package:go_router/go_router.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/core/utils/date_format.dart'; +import 'package:tma_app/features/archive/presentation/pages/archive_list_page.dart'; +import 'package:tma_app/features/archive/presentation/pages/archived_cards_page.dart'; +import 'package:tma_app/features/archive/presentation/pages/archived_diaries_page.dart'; +import 'package:tma_app/features/archive/presentation/pages/archived_pet_info_page.dart'; +import 'package:tma_app/features/archive/presentation/pages/archived_pet_page.dart'; +import 'package:tma_app/features/archive/presentation/pages/pet_archive_move_page.dart'; import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dart'; import 'package:tma_app/features/diary/presentation/pages/diary_page.dart'; @@ -187,6 +193,59 @@ final appRouterProvider = Provider((ref) { path: ':petId', builder: (context, state) => PetInfoPage(petId: state.pathParameters['petId']!), + routes: [ + // 추억 보관함으로 옮기기 — 탭바 없이 전체 화면 + GoRoute( + path: 'archive', + parentNavigatorKey: rootKey, + builder: (context, state) => + PetArchiveMovePage(petId: state.pathParameters['petId']!), + ), + ], + ), + ], + ), + GoRoute( + path: 'archive', + builder: (context, state) => const ArchiveListPage(), + routes: [ + GoRoute( + path: ':petId', + builder: (context, state) => + ArchivedPetPage(petId: state.pathParameters['petId']!), + routes: [ + GoRoute( + path: 'info', + builder: (context, state) => + ArchivedPetInfoPage(petId: state.pathParameters['petId']!), + ), + GoRoute( + path: 'cards', + builder: (context, state) => + ArchivedCardsPage(petId: state.pathParameters['petId']!), + routes: [ + GoRoute( + path: ':cardId', + builder: (context, state) => ArchivedCardDetailPage( + petId: state.pathParameters['petId']!, + cardId: state.pathParameters['cardId']!, + ), + ), + ], + ), + GoRoute( + path: 'diaries', + builder: (context, state) => + ArchivedDiariesPage(petId: state.pathParameters['petId']!), + routes: [ + GoRoute( + path: ':diaryId', + builder: (context, state) => + DiaryPage(diaryId: state.pathParameters['diaryId']!, archived: true), + ), + ], + ), + ], ), ], ), diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 085b8b6..fd6a206 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -18,6 +18,18 @@ abstract final class AppRoutes { // 마이페이지 하위 화면 (탭바 유지) static const String myPets = '/mypage/pets'; static String petInfo(String petId) => '/mypage/pets/$petId'; + + /// 추억 보관함으로 옮기기 — 탭바 없이 전체 화면 + static String petArchive(String petId) => '/mypage/pets/$petId/archive'; + + // 추억 보관함 (탭바 유지) + static const String archive = '/mypage/archive'; + static String archivedPet(String petId) => '/mypage/archive/$petId'; + static String archivedPetInfo(String petId) => '/mypage/archive/$petId/info'; + static String archivedPetCards(String petId) => '/mypage/archive/$petId/cards'; + static String archivedPetCard(String petId, String cardId) => '/mypage/archive/$petId/cards/$cardId'; + static String archivedPetDiaries(String petId) => '/mypage/archive/$petId/diaries'; + static String archivedPetDiary(String petId, String diaryId) => '/mypage/archive/$petId/diaries/$diaryId'; static const String settings = '/mypage/settings'; static String policy(PolicyDoc doc) => '/mypage/settings/policy/${doc.name}'; diff --git a/flutter/lib/core/utils/korean.dart b/flutter/lib/core/utils/korean.dart index 4ed9b30..2d55f30 100644 --- a/flutter/lib/core/utils/korean.dart +++ b/flutter/lib/core/utils/korean.dart @@ -14,5 +14,8 @@ bool hasFinalConsonant(String word) { /// 목적격 조사 — 콩이를 · 초코를 · 보리를 / 루비를 · 사랑을 String withObjectParticle(String word) => '$word${hasFinalConsonant(word) ? '을' : '를'}'; +/// 공동격 조사 — 콩이와 / 사랑과 +String withAndParticle(String word) => '$word${hasFinalConsonant(word) ? '과' : '와'}'; + /// 주격 조사 — 콩이가 / 사랑이 String withSubjectParticle(String word) => '$word${hasFinalConsonant(word) ? '이' : '가'}'; diff --git a/flutter/lib/features/archive/presentation/pages/archive_list_page.dart b/flutter/lib/features/archive/presentation/pages/archive_list_page.dart new file mode 100644 index 0000000..200717f --- /dev/null +++ b/flutter/lib/features/archive/presentation/pages/archive_list_page.dart @@ -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 []).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)), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/archive/presentation/pages/archived_cards_page.dart b/flutter/lib/features/archive/presentation/pages/archived_cards_page.dart new file mode 100644 index 0000000..332db9a --- /dev/null +++ b/flutter/lib/features/archive/presentation/pages/archived_cards_page.dart @@ -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), + ], + ); + } +} diff --git a/flutter/lib/features/archive/presentation/pages/archived_diaries_page.dart b/flutter/lib/features/archive/presentation/pages/archived_diaries_page.dart new file mode 100644 index 0000000..aa964ed --- /dev/null +++ b/flutter/lib/features/archive/presentation/pages/archived_diaries_page.dart @@ -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), + ], + ); + } +} diff --git a/flutter/lib/features/archive/presentation/pages/archived_pet_info_page.dart b/flutter/lib/features/archive/presentation/pages/archived_pet_info_page.dart new file mode 100644 index 0000000..f6f5db5 --- /dev/null +++ b/flutter/lib/features/archive/presentation/pages/archived_pet_info_page.dart @@ -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), + ], + ); + } +} diff --git a/flutter/lib/features/archive/presentation/pages/archived_pet_page.dart b/flutter/lib/features/archive/presentation/pages/archived_pet_page.dart new file mode 100644 index 0000000..0f1098a --- /dev/null +++ b/flutter/lib/features/archive/presentation/pages/archived_pet_page.dart @@ -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 _kHeroShade = [Color(0x0D000000), Color(0x6B000000), Color(0xC7000000)]; +const List _kHeroShadeStops = [.3, .62, 1]; +const List _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 _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), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/archive/presentation/pages/pet_archive_move_page.dart b/flutter/lib/features/archive/presentation/pages/pet_archive_move_page.dart new file mode 100644 index 0000000..f53dab5 --- /dev/null +++ b/flutter/lib/features/archive/presentation/pages/pet_archive_move_page.dart @@ -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 createState() => _PetArchiveMovePageState(); +} + +class _PetArchiveMovePageState extends ConsumerState { + 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 _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 _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 _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)), + ), + ), + ); + } +} diff --git a/flutter/lib/features/archive/presentation/providers/archive_actions.dart b/flutter/lib/features/archive/presentation/providers/archive_actions.dart new file mode 100644 index 0000000..e8dc097 --- /dev/null +++ b/flutter/lib/features/archive/presentation/providers/archive_actions.dart @@ -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 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 restore(String petId) async { + await _ref.read(petListProvider.notifier).restorePet(petId); + _ref.read(toastProvider.notifier).show('다시 불러왔어요'); + } +} + +final archiveActionsProvider = Provider(ArchiveActions.new); diff --git a/flutter/lib/features/archive/presentation/widgets/archive_widgets.dart b/flutter/lib/features/archive/presentation/widgets/archive_widgets.dart new file mode 100644 index 0000000..f933fa0 --- /dev/null +++ b/flutter/lib/features/archive/presentation/widgets/archive_widgets.dart @@ -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), + ], + ); + } +} diff --git a/flutter/lib/features/diary/domain/diary.dart b/flutter/lib/features/diary/domain/diary.dart index d1b1123..0ff4723 100644 --- a/flutter/lib/features/diary/domain/diary.dart +++ b/flutter/lib/features/diary/domain/diary.dart @@ -51,6 +51,10 @@ class Diary { /// 누구의 다이어리인지 — 반려동물 id 또는 나(self) final List targetIds; + + /// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다 + bool isArchivedWith(Set archivedPetIds) => + targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains); final Color ribbon; final Color tint; final String? coverPath; diff --git a/flutter/lib/features/diary/domain/diary_book.dart b/flutter/lib/features/diary/domain/diary_book.dart index befff3f..f88f6da 100644 --- a/flutter/lib/features/diary/domain/diary_book.dart +++ b/flutter/lib/features/diary/domain/diary_book.dart @@ -11,10 +11,24 @@ class DiaryBook { final List posts; final List comments; - bool get atMax => diaries.length >= kMaxDiaries; + /// 책장에 꽂힌 다이어리 — 추억 보관함의 아이만 담은 다이어리는 빠진다 + List shelf([Set archivedPetIds = const {}]) => + diaries.where((d) => !d.isArchivedWith(archivedPetIds)).toList(); + + /// 책장이 가득 찼는지 — 보관함으로 간 다이어리는 세지 않는다 + bool atMax([Set archivedPetIds = const {}]) => shelf(archivedPetIds).length >= kMaxDiaries; Diary? diary(String id) => diaries.where((d) => d.id == id).firstOrNull; + /// 한 아이(또는 나)가 담긴 다이어리 — 추억 보관함에서 함께한 다이어리를 볼 때 + List 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 latest(int limit) => (List.of(posts)..sort(_newestFirst)).take(limit).toList(); + /// 최근 기록 — 홈. [diaryIds] 를 주면 그 다이어리들의 기록만 + List latest(int limit, {Set? diaryIds}) { + final shown = diaryIds == null ? posts : posts.where((p) => diaryIds.contains(p.diaryId)); + return (List.of(shown)..sort(_newestFirst)).take(limit).toList(); + } List commentsOf(String postId) => comments.where((c) => c.postId == postId).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)); diff --git a/flutter/lib/features/diary/presentation/pages/diary_page.dart b/flutter/lib/features/diary/presentation/pages/diary_page.dart index 93fa7f5..818d043 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_page.dart @@ -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 createState() => _DiaryPageState(); } @@ -99,6 +108,7 @@ class _DiaryPageState extends ConsumerState { 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 { 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 { 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 _feedBody(List 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; diff --git a/flutter/lib/features/diary/presentation/providers/diary_providers.dart b/flutter/lib/features/diary/presentation/providers/diary_providers.dart index d3f7ee0..a5c61f7 100644 --- a/flutter/lib/features/diary/presentation/providers/diary_providers.dart +++ b/flutter/lib/features/diary/presentation/providers/diary_providers.dart @@ -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 { @@ -32,7 +33,7 @@ class DiaryNotifier extends AsyncNotifier { 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(DiaryNotif /// 불러오기 전에도 쓸 수 있는 다이어리 (빈 목록으로 시작) final diaryBookProvider = Provider((ref) => ref.watch(diaryProvider).value ?? DiaryBook.empty); + +/// 책장의 다이어리 — 추억 보관함의 아이만 담은 다이어리는 빠진다 +final shelfDiariesProvider = Provider>((ref) => watchShelf(ref)); + +/// 책장의 다이어리를 알림기에서 바로 계산한다 — 다른 파생 프로바이더를 거치지 않게 ([petListProvider] 주석 참고) +List watchShelf(Ref ref) { + final book = ref.watch(diaryProvider).value ?? DiaryBook.empty; + return book.shelf(ref.watch(petListProvider).value?.archivedIds ?? const {}); +} diff --git a/flutter/lib/features/diary/presentation/widgets/diary_cover.dart b/flutter/lib/features/diary/presentation/widgets/diary_cover.dart index d0b919e..4af5e3f 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_cover.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_cover.dart @@ -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, diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart index ce07d89..43f994f 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart @@ -219,7 +219,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> { @override Widget build(BuildContext context) { - final pets = ref.watch(petListProvider).value ?? const []; + final pets = (ref.watch(petListProvider).value ?? const []).active; final names = ref.watch(targetNamesProvider); const gap = SizedBox(height: AppSpacing.lg); diff --git a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart index fb8bb79..9fc5949 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart @@ -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()); diff --git a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart index df49f07..a55b68f 100644 --- a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart @@ -34,7 +34,7 @@ class _HomePetsSectionState extends ConsumerState { @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) { diff --git a/flutter/lib/features/mypage/presentation/pages/my_page.dart b/flutter/lib/features/mypage/presentation/pages/my_page.dart index aab1c51..c867ed2 100644 --- a/flutter/lib/features/mypage/presentation/pages/my_page.dart +++ b/flutter/lib/features/mypage/presentation/pages/my_page.dart @@ -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 []).length; + final petCount = ((ref.watch(petListProvider).value ?? const []).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 []).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}); diff --git a/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart b/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart index e6b1146..03db344 100644 --- a/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart +++ b/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart @@ -29,7 +29,7 @@ class MyPetsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final pets = ref.watch(petListProvider).value ?? const []; + final pets = (ref.watch(petListProvider).value ?? const []).active; final today = DateTime.now(); return TabSubPage( diff --git a/flutter/lib/features/notifications/presentation/providers/notice_providers.dart b/flutter/lib/features/notifications/presentation/providers/notice_providers.dart index 4fefede..f4923a2 100644 --- a/flutter/lib/features/notifications/presentation/providers/notice_providers.dart +++ b/flutter/lib/features/notifications/presentation/providers/notice_providers.dart @@ -35,8 +35,7 @@ final noticeSeenProvider = NotifierProvider(Notic /// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다. final noticesProvider = Provider>((ref) => _watchNotices(ref)); -/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 이으면 Riverpod 3.3 에서 -/// 가려졌던 화면이 다시 보일 때 빌드 도중 재계산이 번져 markNeedsBuild 오류가 난다 +/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고) List _watchNotices(Ref ref) { final now = ref.watch(clockProvider)(); // 저녁 8시·자정에 다시 계산한다 — 앱을 켜 둔 채 시각이 지나도 기록 알림이 맞게 나타나고 사라진다 @@ -54,7 +53,8 @@ List _watchNotices(Ref ref) { final notices = []; 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)}', diff --git a/flutter/lib/features/pet/data/repositories/pet_repository.dart b/flutter/lib/features/pet/data/repositories/pet_repository.dart index d67ecec..13ee5eb 100644 --- a/flutter/lib/features/pet/data/repositories/pet_repository.dart +++ b/flutter/lib/features/pet/data/repositories/pet_repository.dart @@ -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 update(String id, PetInput input); Future delete(String id); + + /// 추억 보관함으로 옮긴다 — 아이와 정보는 그대로 두고 보관 기록을 붙인다 + Future archive(String id, PetArchive archive); + + /// 보관함에서 다시 불러온다 + Future restore(String id); } const String _kPetsKey = 'pets.v1'; @@ -55,9 +62,22 @@ class LocalPetRepository implements PetRepository { @override Future 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 archive(String id, PetArchive archive) => _setArchive(id, archive); + + @override + Future restore(String id) => _setArchive(id, null); + + Future _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; } diff --git a/flutter/lib/features/pet/domain/pet.dart b/flutter/lib/features/pet/domain/pet.dart index 8ceeced..e7062d5 100644 --- a/flutter/lib/features/pet/domain/pet.dart +++ b/flutter/lib/features/pet/domain/pet.dart @@ -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 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 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 a => PetArchive.fromJson(a), + _ => null, + }, ); } diff --git a/flutter/lib/features/pet/domain/pet_archive.dart b/flutter/lib/features/pet/domain/pet_archive.dart new file mode 100644 index 0000000..db1dc74 --- /dev/null +++ b/flutter/lib/features/pet/domain/pet_archive.dart @@ -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 toJson() => { + 'from': toIsoDate(from), + 'to': toIsoDate(to), + 'archivedAt': archivedAt.toIso8601String(), + 'memo': memo, + }; + + factory PetArchive.fromJson(Map 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(' '); +} diff --git a/flutter/lib/features/pet/presentation/pages/pet_info_page.dart b/flutter/lib/features/pet/presentation/pages/pet_info_page.dart index ba79d10..415de51 100644 --- a/flutter/lib/features/pet/presentation/pages/pet_info_page.dart +++ b/flutter/lib/features/pet/presentation/pages/pet_info_page.dart @@ -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 _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 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 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), - ), - ), - ], - ), - ); - } -} diff --git a/flutter/lib/features/pet/presentation/providers/pet_providers.dart b/flutter/lib/features/pet/presentation/providers/pet_providers.dart index fcd97d2..6065683 100644 --- a/flutter/lib/features/pet/presentation/providers/pet_providers.dart +++ b/flutter/lib/features/pet/presentation/providers/pet_providers.dart @@ -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> { @@ -29,10 +30,38 @@ class PetListNotifier extends AsyncNotifier> { state = AsyncData([...?state.value?.where((p) => p.id != id)]); } } + + Future archivePet(String id, PetArchive archive) async => _replace(await _repo.archive(id, archive)); + + Future 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 []) 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.new); +/// 함께 지내는 아이 / 추억 보관함의 아이로 가르기 +extension PetListX on List { + /// 함께 지내는 아이 — 추억 보관함으로 옮긴 아이는 빠진다 + List get active => where((p) => !p.isArchived).toList(); + + /// 추억 보관함의 아이 — 최근에 보관한 아이가 위로 + List get archived => + where((p) => p.isArchived).toList()..sort((a, b) => b.archive!.archivedAt.compareTo(a.archive!.archivedAt)); + + /// 보관된 아이 id — 다이어리를 가를 때 + Set get archivedIds => {for (final p in this) if (p.isArchived) p.id}; +} + /// 홈에서 고른 아이 id class SelectedPetIdNotifier extends Notifier { @override @@ -45,14 +74,14 @@ final selectedPetIdProvider = NotifierProvider( SelectedPetIdNotifier.new, ); -/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌으면 첫 번째 아이 +/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌거나 보관됐으면 첫 번째 아이 final selectedPetProvider = Provider((ref) { - final pets = ref.watch(petListProvider).value ?? const []; + final pets = (ref.watch(petListProvider).value ?? const []).active; final id = ref.watch(selectedPetIdProvider); return pets.where((p) => p.id == id).firstOrNull ?? pets.firstOrNull; }); -/// 반려동물 id 로 찾기 — 수정 화면 등 +/// 반려동물 id 로 찾기 — 수정 화면 등 (보관된 아이도 찾는다) final petByIdProvider = Provider.family((ref, id) { final pets = ref.watch(petListProvider).value ?? const []; return pets.where((p) => p.id == id).firstOrNull; diff --git a/flutter/lib/features/pet/presentation/widgets/pet_info_sections.dart b/flutter/lib/features/pet/presentation/widgets/pet_info_sections.dart new file mode 100644 index 0000000..28302ac --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_info_sections.dart @@ -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 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 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 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), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart index 88d4b7e..8021a40 100644 --- a/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart @@ -46,7 +46,7 @@ class _PetCardManagePageState extends ConsumerState { @override Widget build(BuildContext context) { - final pets = ref.watch(petListProvider).value ?? const []; + final pets = (ref.watch(petListProvider).value ?? const []).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; diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart index 2a9a582..6b5371d 100644 --- a/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart @@ -43,7 +43,7 @@ class _PetCardsPageState extends ConsumerState { @override Widget build(BuildContext context) { - final pets = ref.watch(petListProvider).value ?? const []; + final pets = (ref.watch(petListProvider).value ?? const []).active; final pet = pets.where((p) => p.id == _selectedPetId).firstOrNull ?? pets.firstOrNull; Widget padded(Widget child) => diff --git a/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart b/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart index 3128461..4a8b439 100644 --- a/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart +++ b/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart @@ -77,14 +77,13 @@ class PetCardNotifier extends AsyncNotifier { final petCardsProvider = AsyncNotifierProvider(PetCardNotifier.new); -/// 지금 등록된 아이들의 명함만 (지워진 아이의 명함은 숨긴다) +/// 함께 지내는 아이들의 명함만 (지워진 아이·추억 보관함의 아이 명함은 숨긴다) final myCardsProvider = Provider>((ref) => _watchMyCards(ref)); -/// 알림기에서 바로 계산한다 — 파생 프로바이더끼리 이으면 Riverpod 3.3 에서 가려졌던 화면이 -/// 다시 보일 때 빌드 도중 재계산이 번져 markNeedsBuild 오류가 난다 +/// 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 (petListProvider 주석 참고) List _watchMyCards(Ref ref) { final cards = ref.watch(petCardsProvider).value?.cards ?? const []; - final petIds = {for (final p in ref.watch(petListProvider).value ?? const []) p.id}; + final petIds = {for (final p in (ref.watch(petListProvider).value ?? const []).active) p.id}; return cards.where((c) => petIds.contains(c.petId)).toList(); } @@ -92,6 +91,12 @@ final cardByIdProvider = Provider.family((ref, cardId) { return _watchMyCards(ref).where((c) => c.id == cardId).firstOrNull; }); +/// 한 아이의 명함 — 추억 보관함에서 보관된 아이의 명함을 다시 볼 때 +final cardsOfPetProvider = Provider.family, String>((ref, petId) { + final cards = ref.watch(petCardsProvider).value?.cards ?? const []; + return cards.where((c) => c.petId == petId).toList(); +}); + /// 아이의 대표 명함 — 지정한 것이 없으면 그 아이의 첫 명함 final representativeCardProvider = Provider.family((ref, petId) { final cards = _watchMyCards(ref).where((c) => c.petId == petId).toList(); diff --git a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart index fa794e6..6797f11 100644 --- a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart +++ b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart @@ -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()); diff --git a/flutter/lib/shared/widgets/grayscale.dart b/flutter/lib/shared/widgets/grayscale.dart new file mode 100644 index 0000000..076f1d1 --- /dev/null +++ b/flutter/lib/shared/widgets/grayscale.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; + +// CSS filter: grayscale() 가 쓰는 밝기 가중치 +const double _kR = 0.2126; +const double _kG = 0.7152; +const double _kB = 0.0722; + +/// CSS `filter: grayscale(amount)` 와 같은 색 행렬 — 0 이면 그대로, 1 이면 흑백 +ColorFilter grayscaleFilter(double amount) { + final k = 1 - amount.clamp(0.0, 1.0); + return ColorFilter.matrix([ + _kR + (1 - _kR) * k, _kG - _kG * k, _kB - _kB * k, 0, 0, // + _kR - _kR * k, _kG + (1 - _kG) * k, _kB - _kB * k, 0, 0, // + _kR - _kR * k, _kG - _kG * k, _kB + (1 - _kB) * k, 0, 0, // + 0, 0, 0, 1, 0, + ]); +} + +/// 빛바랜 사진 — 추억 보관함의 아이 사진에 쓴다 +class Grayscale extends StatelessWidget { + const Grayscale({super.key, required this.amount, required this.child}); + + final double amount; + final Widget child; + + @override + Widget build(BuildContext context) => ColorFiltered(colorFilter: grayscaleFilter(amount), child: child); +} diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart index a692644..1af2560 100644 --- a/flutter/lib/shared/widgets/rup_button.dart +++ b/flutter/lib/shared/widgets/rup_button.dart @@ -24,6 +24,9 @@ enum RupButtonVariant { /// 주황 화면 위의 흰 버튼 (인트로·온보딩 CTA — .rup-btn 이 아니라 본문 서체) light, + + /// 추억 보관함 보라 (보관하기 확인) + memory, } const double _kFontSize = 15; @@ -141,6 +144,7 @@ class RupButton extends StatelessWidget { RupButtonVariant.danger => (AppColors.danger, Colors.white, null), RupButtonVariant.alert => (AppColors.catHospital, Colors.white, AppColors.catHospital), RupButtonVariant.light => (AppColors.offWhite, AppColors.ink, null), + RupButtonVariant.memory => (AppColors.memory, Colors.white, AppColors.memory), }; final size = large ? _kFontSizeLarge : _kFontSize; final textStyle = variant == RupButtonVariant.light diff --git a/flutter/lib/shared/widgets/rup_dialog.dart b/flutter/lib/shared/widgets/rup_dialog.dart index f0919ca..4a5cc95 100644 --- a/flutter/lib/shared/widgets/rup_dialog.dart +++ b/flutter/lib/shared/widgets/rup_dialog.dart @@ -26,8 +26,10 @@ Future showRupConfirm( bool destructive = false, RupConfirmLayout layout = RupConfirmLayout.stacked, RupIconName? icon, + RupGlyph? glyph, Color iconColor = AppColors.ink2, Color? iconBackground, + RupButtonVariant? confirmVariant, }) async { final result = await showDialog( context: context, @@ -55,7 +57,7 @@ Future showRupConfirm( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (row) const SizedBox(height: AppSpacing.xs), - if (icon != null) + if (icon != null || glyph != null) Center( child: Container( width: 54, @@ -66,7 +68,9 @@ Future showRupConfirm( borderRadius: BorderRadius.circular(AppSpacing.lg), ), alignment: Alignment.center, - child: RupIcon(icon, size: 26, stroke: 2, color: iconColor), + child: glyph != null + ? RupGlyphIcon(glyph, size: 26, color: iconColor) + : RupIcon(icon!, size: 26, stroke: 2, color: iconColor), ), ), Text( @@ -99,7 +103,7 @@ Future showRupConfirm( Expanded( child: RupButton( label: confirmLabel, - variant: destructive ? RupButtonVariant.alert : RupButtonVariant.primary, + variant: confirmVariant ?? (destructive ? RupButtonVariant.alert : RupButtonVariant.primary), onPressed: () => Navigator.of(context).pop(true), ), ), @@ -109,7 +113,7 @@ Future showRupConfirm( RupButton( label: confirmLabel, large: true, - variant: destructive ? RupButtonVariant.danger : RupButtonVariant.primary, + variant: confirmVariant ?? (destructive ? RupButtonVariant.danger : RupButtonVariant.primary), onPressed: () => Navigator.of(context).pop(true), ), const SizedBox(height: AppSpacing.sm), diff --git a/flutter/lib/shared/widgets/section_title.dart b/flutter/lib/shared/widgets/section_title.dart index c7f75c9..944bacc 100644 --- a/flutter/lib/shared/widgets/section_title.dart +++ b/flutter/lib/shared/widgets/section_title.dart @@ -102,13 +102,16 @@ class EmptyMessage extends StatelessWidget { /// 화면 폭 전체를 가로지르는 구분 띠 (시안 .rup-divider-band) class DividerBand extends StatelessWidget { - const DividerBand({super.key}); + const DividerBand({super.key, this.top = 34}); + + /// 띠 위 여백 — 시안이 화면마다 덮어쓰는 곳이 있다 (추억 보관함 18) + final double top; @override Widget build(BuildContext context) { - return const Padding( - padding: EdgeInsets.only(top: 34, bottom: 22), - child: SizedBox(height: 8, width: double.infinity, child: ColoredBox(color: AppColors.sand)), + return Padding( + padding: EdgeInsets.only(top: top, bottom: 22), + child: const SizedBox(height: 8, width: double.infinity, child: ColoredBox(color: AppColors.sand)), ); } } diff --git a/flutter/lib/shared/widgets/sub_page_header.dart b/flutter/lib/shared/widgets/sub_page_header.dart index 039f18e..993f8b9 100644 --- a/flutter/lib/shared/widgets/sub_page_header.dart +++ b/flutter/lib/shared/widgets/sub_page_header.dart @@ -14,6 +14,9 @@ class SubPageHeader extends StatelessWidget { this.subtitle, this.trailing, this.displayFont = false, + this.titleLeading, + this.subtitleLeading, + this.bottomGap = 18, }); final String title; @@ -24,6 +27,13 @@ class SubPageHeader extends StatelessWidget { /// 제목을 제목 서체로 (시안에서 화면마다 다르다) final bool displayFont; + /// 제목·부제 앞의 작은 아이콘 (추억 보관함의 구름 등) + final Widget? titleLeading; + final Widget? subtitleLeading; + + /// 머리 아래 여백 (시안 대부분 18) + final double bottomGap; + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; @@ -37,7 +47,7 @@ class SubPageHeader extends StatelessWidget { ) : textTheme.titleSmall!.copyWith(fontSize: 18, fontWeight: FontWeight.w800, height: 1.25); return Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs, bottom: 18), + padding: EdgeInsets.only(top: AppSpacing.xs, bottom: bottomGap), child: Row( children: [ Pressable( @@ -54,12 +64,20 @@ class SubPageHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: titleStyle), + _withLeading( + titleLeading, + gap: 7, + child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: titleStyle), + ), if (subtitle != null) ...[ const SizedBox(height: 1), - Text( - subtitle!, - style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight), + _withLeading( + subtitleLeading, + gap: 5, + child: Text( + subtitle!, + style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight), + ), ), ], ], @@ -70,4 +88,9 @@ class SubPageHeader extends StatelessWidget { ), ); } + + static Widget _withLeading(Widget? leading, {required double gap, required Widget child}) { + if (leading == null) return child; + return Row(children: [leading, SizedBox(width: gap), Flexible(child: child)]); + } } diff --git a/flutter/test/archive_domain_test.dart b/flutter/test/archive_domain_test.dart new file mode 100644 index 0000000..220585a --- /dev/null +++ b/flutter/test/archive_domain_test.dart @@ -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 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 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']); + }); + }); +} diff --git a/flutter/test/archive_flow_test.dart b/flutter/test/archive_flow_test.dart new file mode 100644 index 0000000..ae8919f --- /dev/null +++ b/flutter/test/archive_flow_test.dart @@ -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 _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 _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 _openMyPage(WidgetTester tester) async { + await tester.tap(find.bySemanticsLabel('마이페이지')); + await settle(tester); +} + +Future _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); + }); +} diff --git a/flutter/test/diary_domain_test.dart b/flutter/test/diary_domain_test.dart index f53df3a..647d85c 100644 --- a/flutter/test/diary_domain_test.dart +++ b/flutter/test/diary_domain_test.dart @@ -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); }); });