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