generated from ttipo/project-base
Compare commits
10 Commits
d7f46d174a
...
1f7693898c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7693898c | |||
| 0c16763943 | |||
| e6f5cd0720 | |||
| 4173c64af8 | |||
| 81af93a813 | |||
| d9a4eba9a3 | |||
| 03cb0224d6 | |||
| 795509be1b | |||
| 877c833f7d | |||
| 66fb329747 |
@@ -1,8 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 명함 이미지를 앨범에 저장 (Android 10 이하만 필요 — gal) -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
||||
<application
|
||||
android:label="tma_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tma App</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>펫 명함 이미지를 사진 보관함에 저장합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>우리 아이 프로필 사진을 등록하기 위해 사진 보관함에 접근합니다.</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -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';
|
||||
@@ -16,6 +22,7 @@ import 'package:tma_app/features/mypage/presentation/pages/my_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/policy_doc_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/pages/notifications_page.dart';
|
||||
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart';
|
||||
@@ -97,6 +104,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => EventEditorPage.edit(eventId: state.uri.queryParameters['id'] ?? ''),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.notifications,
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => const NotificationsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.diaryPattern,
|
||||
parentNavigatorKey: rootKey,
|
||||
@@ -181,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}';
|
||||
|
||||
@@ -32,6 +44,9 @@ abstract final class AppRoutes {
|
||||
static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}';
|
||||
static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}';
|
||||
|
||||
// 알림 — 홈 종에서 연다 (탭바 없이 전체 화면)
|
||||
static const String notifications = '/notifications';
|
||||
|
||||
// 다이어리 — 열면 탭바 없이 전체 화면
|
||||
static const String diaryPattern = '/diaries/:diaryId';
|
||||
static String diary(String id) => '/diaries/$id';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// 이미지를 기기 앨범(사진 보관함)에 저장한다 — 테스트에서 바꿔 끼울 수 있게 계약으로 둔다
|
||||
abstract interface class AlbumSaver {
|
||||
/// 저장했으면 true — 권한을 거절했거나 실패하면 false
|
||||
Future<bool> savePng(Uint8List bytes, String name);
|
||||
}
|
||||
|
||||
/// gal 로 앨범에 저장한다. 웹에는 앨범이 없어 파일 내려받기(공유)로 대신한다
|
||||
class GalAlbumSaver implements AlbumSaver {
|
||||
const GalAlbumSaver();
|
||||
|
||||
@override
|
||||
Future<bool> savePng(Uint8List bytes, String name) async {
|
||||
if (kIsWeb) {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: '$name.png')]),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!await Gal.hasAccess() && !await Gal.requestAccess()) return false;
|
||||
await Gal.putImageBytes(bytes, name: name);
|
||||
return true;
|
||||
} on GalException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final albumSaverProvider = Provider<AlbumSaver>((ref) => const GalAlbumSaver());
|
||||
@@ -71,13 +71,13 @@ abstract final class AppTypography {
|
||||
// 13 — 작은 버튼·링크
|
||||
labelMedium: _ui(13, FontWeight.w600),
|
||||
// 12 — 하단 탭 라벨 (.rup-tab 은 제목 서체 800)
|
||||
labelSmall: _display(12, FontWeight.w800),
|
||||
labelSmall: _ui(12, FontWeight.w800),
|
||||
);
|
||||
|
||||
/// `.rup-btn` · 선택 버튼 — 시안은 모든 버튼을 제목 서체 800 으로 통일한다
|
||||
static TextStyle button(double size, {bool thin = false, Color? color}) {
|
||||
// 제목 서체의 기본 줄 높이가 커서 버튼이 시안보다 높아지므로 줄 높이를 고정한다
|
||||
return _display(size, thin ? FontWeight.w600 : FontWeight.w800, height: 1.2)
|
||||
// 시안 .rup-btn 은 제목 서체 + line-height normal (브라우저 실측 1.10)
|
||||
return _display(size, thin ? FontWeight.w600 : FontWeight.w800, height: displayNormalHeight)
|
||||
.copyWith(color: color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 지금 시각 — 테스트에서 특정 시각(예: 저녁 8시)을 재현할 수 있게 주입한다
|
||||
final clockProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
||||
@@ -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,71 @@
|
||||
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,
|
||||
fontWeight: FontWeight.w800,
|
||||
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,412 @@
|
||||
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,
|
||||
fontWeight: FontWeight.w800,
|
||||
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,28 @@
|
||||
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/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('추억보관함에 보관했어요.', tone: ToastTone.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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -22,7 +24,9 @@ const Size _kArtSize = Size(327, 179);
|
||||
const Alignment _kArtAlignment = Alignment(.33, .68);
|
||||
|
||||
/// 시안: 버튼 묶음은 화면 아래에서 76 (홈 인디케이터 34 포함)
|
||||
const double _kButtonsBottomGap = 42;
|
||||
/// 시안: 인사말은 화면 위에서 96, 버튼 묶음은 화면 아래에서 76 (.rup-login__head / __btns)
|
||||
const double _kHeadTop = 96;
|
||||
const double _kButtonsBottom = 76;
|
||||
const double _kButtonGap = 15;
|
||||
|
||||
/// 로그인 — 소셜 계정으로 가입/로그인한다.
|
||||
@@ -55,6 +59,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final insets = MediaQuery.paddingOf(context);
|
||||
// 화면 가장자리 기준 위치를 따르되, 상태바·홈 인디케이터가 크면 그만큼 비켜 준다
|
||||
final headGap = math.max(_kHeadTop - insets.top - AppControlSize.navBarHeight, AppSpacing.xxl);
|
||||
final buttonsBottom = math.max(_kButtonsBottom, insets.bottom + AppSpacing.xl);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.dark,
|
||||
child: Scaffold(
|
||||
@@ -66,12 +74,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
children: [
|
||||
_LoginNavBar(onBack: () => context.go(AppRoutes.intro)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xl,
|
||||
0,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(AppSpacing.xl, headGap, AppSpacing.xl, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -93,28 +96,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
const Expanded(
|
||||
child: Align(alignment: _kArtAlignment, child: _LoginArt()),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.only(bottom: AppSpacing.xl),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xl,
|
||||
_kButtonsBottomGap,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final provider in SocialProvider.values) ...[
|
||||
if (provider != SocialProvider.values.first)
|
||||
const SizedBox(height: _kButtonGap),
|
||||
SocialLoginButton(
|
||||
provider: provider,
|
||||
onPressed: _pending == null ? () => _signIn(provider) : null,
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.lg, AppSpacing.xl, buttonsBottom),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final provider in SocialProvider.values) ...[
|
||||
if (provider != SocialProvider.values.first)
|
||||
const SizedBox(height: _kButtonGap),
|
||||
SocialLoginButton(
|
||||
provider: provider,
|
||||
onPressed: _pending == null ? () => _signIn(provider) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -86,7 +86,8 @@ class SocialLoginButton extends StatelessWidget {
|
||||
final style = _styleOf(provider);
|
||||
return Pressable(
|
||||
onTap: onPressed,
|
||||
pressedScale: .98,
|
||||
pressedScale: 1,
|
||||
pressedBrightness: .95,
|
||||
semanticLabel: style.label,
|
||||
child: Container(
|
||||
height: AppControlSize.buttonHeight,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -68,7 +68,8 @@ class _DiaryComposePageState extends ConsumerState<DiaryComposePage> {
|
||||
photoPath: _photo,
|
||||
);
|
||||
toast.show('기록을 남겼어요');
|
||||
if (mounted) context.pop();
|
||||
// 남겼음을 알려 다이어리가 피드 보기로 돌아가게 한다
|
||||
if (mounted) context.pop(true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -83,7 +92,13 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
|
||||
ref.read(toastProvider.notifier).show('오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요');
|
||||
return;
|
||||
}
|
||||
unawaited(context.push(AppRoutes.diaryWrite(widget.diaryId)));
|
||||
unawaited(_openCompose());
|
||||
}
|
||||
|
||||
/// 기록하기 — 남기고 돌아오면 달력이 아니라 피드로 보여 준다
|
||||
Future<void> _openCompose() async {
|
||||
final saved = await context.push<bool>(AppRoutes.diaryWrite(widget.diaryId));
|
||||
if (saved == true && mounted) setState(() => _calendar = false);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -99,6 +114,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 +123,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 - _kEmbeddedTrim,
|
||||
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: 18 - _kEmbeddedTrim,
|
||||
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,33 +168,35 @@ 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;
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: _mode.title, sub: true),
|
||||
child: SectionTitle(title: _mode.title, sub: true, subDisplayFont: true),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (posts.isEmpty)
|
||||
RupCard(
|
||||
child: searching
|
||||
? const EmptyMessage('검색 결과가 없어요', padding: 40)
|
||||
? const EmptyMessage('검색 결과가 없어요')
|
||||
: _NoPosts(onWrite: wroteToday ? null : () => _write(false)),
|
||||
)
|
||||
else if (_mode == FeedMode.image && shown.isEmpty)
|
||||
@@ -303,11 +330,33 @@ class _HeaderIconButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 검색칸 — 돋보기 + 지우기
|
||||
class _SearchField extends StatelessWidget {
|
||||
/// 입력 중이면 주황 테두리 (시안 .rup-input:focus)
|
||||
class _SearchField extends StatefulWidget {
|
||||
const _SearchField({required this.controller});
|
||||
|
||||
final TextEditingController controller;
|
||||
|
||||
@override
|
||||
State<_SearchField> createState() => _SearchFieldState();
|
||||
}
|
||||
|
||||
class _SearchFieldState extends State<_SearchField> {
|
||||
final _focus = FocusNode();
|
||||
|
||||
TextEditingController get controller => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14, height: 1.2);
|
||||
@@ -317,7 +366,7 @@ class _SearchField extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
border: Border.all(color: AppColors.line, width: 1.5),
|
||||
border: Border.all(color: _focus.hasFocus ? AppColors.primary : AppColors.line, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -326,6 +375,7 @@ class _SearchField extends StatelessWidget {
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: _focus,
|
||||
style: style,
|
||||
cursorColor: AppColors.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
|
||||
@@ -39,13 +39,10 @@ class DiarySettingsPage extends ConsumerWidget {
|
||||
final String diaryId;
|
||||
|
||||
Future<void> _delete(BuildContext context, WidgetRef ref, Diary diary) async {
|
||||
final ok = await showRupConfirm(
|
||||
final ok = await showRupRemoveConfirm(
|
||||
context,
|
||||
title: '다이어리를 제거할까요?',
|
||||
message: '다이어리를 삭제하면 다시 복구할 수 없으며,\n이때까지 적은 기록이 전부 삭제돼요.',
|
||||
confirmLabel: '제거하기',
|
||||
destructive: true,
|
||||
layout: RupConfirmLayout.row,
|
||||
);
|
||||
if (!ok || !context.mounted) return;
|
||||
// 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다
|
||||
@@ -143,7 +140,7 @@ class DiarySettingsPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const SizedBox(width: 13),
|
||||
HeartToggle(
|
||||
value: diary.notifies(row.notice),
|
||||
label: row.title,
|
||||
|
||||
@@ -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 {});
|
||||
}
|
||||
|
||||
@@ -36,14 +36,26 @@ class MoodIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 사진이 없는 표지 칸의 바탕 막 (시안 image-slot 의 rgba(0,0,0,.04))
|
||||
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;
|
||||
@@ -68,7 +80,7 @@ class DiaryCoverTile extends StatelessWidget {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: diary.tint),
|
||||
if (cover != null) PetPhotoImage(path: cover),
|
||||
if (cover != null) PetPhotoImage(path: cover) else const ColoredBox(color: _kEmptyCoverShade),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -80,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,
|
||||
|
||||
@@ -121,8 +121,9 @@ class _PolaroidPostState extends State<PolaroidPost> {
|
||||
Widget build(BuildContext context) {
|
||||
final post = widget.post;
|
||||
final photo = post.photoPath;
|
||||
// 시안 margin 4px 2px 28px — 위 4 는 앞 요소의 아래 여백(제목 12, 앞 기록 28)과 겹쳐 사라진다
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 4, 2, 28),
|
||||
padding: const EdgeInsets.fromLTRB(2, 0, 2, 28),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
@@ -189,10 +190,40 @@ class _Tape extends StatelessWidget {
|
||||
),
|
||||
boxShadow: [BoxShadow(color: Color(0x12000000), blurRadius: 3, offset: Offset(0, 1))],
|
||||
),
|
||||
// 시안: 양옆 1px 흰 점선 (rgba(255,255,255,.55))
|
||||
foregroundDecoration: const _DashedSides(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 좌우 가장자리에 세로 점선 — CSS border-left/right: 1px dashed
|
||||
class _DashedSides extends Decoration {
|
||||
const _DashedSides();
|
||||
|
||||
@override
|
||||
BoxPainter createBoxPainter([VoidCallback? onChanged]) => _DashedSidesPainter();
|
||||
}
|
||||
|
||||
class _DashedSidesPainter extends BoxPainter {
|
||||
static const double _dash = 3;
|
||||
static const Color _color = Color(0x8CFFFFFF);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||
final size = configuration.size;
|
||||
if (size == null) return;
|
||||
final paint = Paint()
|
||||
..color = _color
|
||||
..strokeWidth = 1;
|
||||
final bottom = offset.dy + size.height;
|
||||
for (final x in [offset.dx + .5, offset.dx + size.width - .5]) {
|
||||
for (var y = offset.dy; y < bottom; y += _dash * 2) {
|
||||
canvas.drawLine(Offset(x, y), Offset(x, y + _dash > bottom ? bottom : y + _dash), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "댓글 N" — 누르면 댓글을 펼친다
|
||||
class _CommentToggle extends ConsumerWidget {
|
||||
const _CommentToggle({required this.postId, required this.open, required this.onTap});
|
||||
@@ -282,6 +313,7 @@ class _CommentsState extends ConsumerState<_Comments> {
|
||||
message: '삭제한 댓글은 다시 볼 수 없어요.',
|
||||
confirmLabel: '삭제하기',
|
||||
layout: RupConfirmLayout.row,
|
||||
tightTop: true,
|
||||
);
|
||||
if (ok) await ref.read(diaryProvider.notifier).deleteComment(c.id);
|
||||
}
|
||||
@@ -328,14 +360,18 @@ class _CommentsState extends ConsumerState<_Comments> {
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
// 시안: 수정칸 위 5, 댓글 글 위 6
|
||||
SizedBox(height: _editingId == c.id ? 5 : 6),
|
||||
if (_editingId == c.id)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _CommentInput(controller: _edit, autofocus: true, onSubmit: _saveEdit)),
|
||||
Expanded(
|
||||
child: _CommentInput(controller: _edit, autofocus: true, compact: true, onSubmit: _saveEdit),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_SoftIconButton(
|
||||
label: '수정 저장',
|
||||
horizontal: 13,
|
||||
onTap: () => unawaited(_saveEdit()),
|
||||
child: const RupIcon(RupIconName.check, size: 16, stroke: 2.4, color: AppColors.primary600),
|
||||
),
|
||||
@@ -357,13 +393,11 @@ class _CommentsState extends ConsumerState<_Comments> {
|
||||
child: _CommentInput(controller: _draft, hint: '댓글 달기…', onSubmit: _add),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Opacity(
|
||||
opacity: _draft.text.trim().isEmpty ? .5 : 1,
|
||||
child: _SoftIconButton(
|
||||
label: '등록',
|
||||
onTap: () => unawaited(_add()),
|
||||
child: Text('등록', style: _ui(context, 13, FontWeight.w700, AppColors.primary600)),
|
||||
),
|
||||
// 시안 .rup-btn — 제목 서체 800, 비어 있어도 모양은 그대로 (누르면 아무 일도 없다)
|
||||
_SoftIconButton(
|
||||
label: '등록',
|
||||
onTap: () => unawaited(_add()),
|
||||
child: Text('등록', style: AppTypography.button(13, color: AppColors.primary600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -387,12 +421,15 @@ class _SmallAction extends StatelessWidget {
|
||||
|
||||
/// 연주황 작은 버튼 (.rup-btn--soft) — 등록 · ✓
|
||||
class _SoftIconButton extends StatelessWidget {
|
||||
const _SoftIconButton({required this.label, required this.onTap, required this.child});
|
||||
const _SoftIconButton({required this.label, required this.onTap, required this.child, this.horizontal = 14});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
|
||||
/// 좌우 여백 — 등록 14, 수정 저장 13
|
||||
final double horizontal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
@@ -402,7 +439,7 @@ class _SoftIconButton extends StatelessWidget {
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontal, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
@@ -415,34 +452,64 @@ class _SoftIconButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 댓글 입력칸 — 50자까지
|
||||
class _CommentInput extends StatelessWidget {
|
||||
const _CommentInput({required this.controller, required this.onSubmit, this.hint, this.autofocus = false});
|
||||
/// 댓글 입력칸 — 50자까지. 입력 중이면 주황 테두리 (시안 .rup-input:focus)
|
||||
class _CommentInput extends StatefulWidget {
|
||||
const _CommentInput({
|
||||
required this.controller,
|
||||
required this.onSubmit,
|
||||
this.hint,
|
||||
this.autofocus = false,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final Future<void> Function() onSubmit;
|
||||
final String? hint;
|
||||
final bool autofocus;
|
||||
|
||||
/// 댓글 수정칸 — 여백 8/11, 모서리 9 (새 댓글은 9/12, 10)
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
State<_CommentInput> createState() => _CommentInputState();
|
||||
}
|
||||
|
||||
class _CommentInputState extends State<_CommentInput> {
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = _ui(context, 13.5, FontWeight.w400, AppColors.ink, height: 1.2);
|
||||
final compact = widget.compact;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 11 : 12, vertical: compact ? 8 : 9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
border: Border.all(color: AppColors.line, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(compact ? 9 : AppRadius.card),
|
||||
border: Border.all(color: _focus.hasFocus ? AppColors.primary : AppColors.line, width: 1.5),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
autofocus: autofocus,
|
||||
controller: widget.controller,
|
||||
focusNode: _focus,
|
||||
autofocus: widget.autofocus,
|
||||
maxLength: kCommentMaxLength,
|
||||
style: style,
|
||||
cursorColor: AppColors.primary,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => unawaited(onSubmit()),
|
||||
decoration: InputDecoration.collapsed(hintText: hint, hintStyle: style.copyWith(color: AppColors.muted))
|
||||
onSubmitted: (_) => unawaited(widget.onSubmit()),
|
||||
decoration: InputDecoration.collapsed(hintText: widget.hint, hintStyle: style.copyWith(color: AppColors.muted))
|
||||
.copyWith(counterText: ''),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -131,7 +132,10 @@ Future<void> showAddDiarySheet(BuildContext context, WidgetRef ref) {
|
||||
iconBackground: AppColors.accent100,
|
||||
title: '공유 다이어리 참여하기',
|
||||
description: '초대 코드로 함께 쓰는 다이어리에 참여해요',
|
||||
onTap: () => ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -219,7 +223,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);
|
||||
|
||||
@@ -261,6 +265,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
|
||||
children: [
|
||||
RupSegmented<bool>(
|
||||
options: const [(false, '개인 다이어리'), (true, '공유 다이어리')],
|
||||
icons: const [RupIconName.user, RupIconName.users],
|
||||
value: false,
|
||||
onChanged: (shared) {
|
||||
if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
|
||||
@@ -304,6 +309,8 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
|
||||
label: '책갈피 리본 색상',
|
||||
child: RibbonPicker(color: _ribbon, onPick: (c) => setState(() => _ribbon = c), onCustom: _pickCustomRibbon),
|
||||
),
|
||||
// 시안 .rup-field 아래 16 (바닥 버튼 위 18 과 겹치지 않는다)
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
RupSheetBleedButton(
|
||||
label: _editing ? '수정 완료' : '다이어리 만들기',
|
||||
onPressed: () => unawaited(_submit()),
|
||||
@@ -324,10 +331,14 @@ class RibbonPicker extends StatelessWidget {
|
||||
|
||||
static const double _size = 36;
|
||||
|
||||
static const _rainbow = SweepGradient(colors: [
|
||||
Color(0xFFFF7500), Color(0xFFFF7AA0), Color(0xFF8E7CF0), Color(0xFF4DA6E0),
|
||||
Color(0xFF3FB58C), Color(0xFFFFD43B), Color(0xFFFF7500),
|
||||
]);
|
||||
// CSS conic-gradient 는 12시에서 시작한다 (Flutter 기본은 3시)
|
||||
static const _rainbow = SweepGradient(
|
||||
transform: GradientRotation(-math.pi / 2),
|
||||
colors: [
|
||||
Color(0xFFFF7500), Color(0xFFFF7AA0), Color(0xFF8E7CF0), Color(0xFF4DA6E0),
|
||||
Color(0xFF3FB58C), Color(0xFFFFD43B), Color(0xFFFF7500),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -384,11 +395,17 @@ class RibbonPicker extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
child: custom
|
||||
? check(color)
|
||||
: const RupIcon(
|
||||
RupIconName.plus,
|
||||
size: 16,
|
||||
stroke: 2.6,
|
||||
color: Colors.white,
|
||||
: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Color(0x33000000), offset: Offset(0, 1), blurRadius: 2)],
|
||||
),
|
||||
child: RupIcon(
|
||||
RupIconName.plus,
|
||||
size: 16,
|
||||
stroke: 2.6,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -439,6 +456,9 @@ Future<PostAction?> showPostMenuSheet(BuildContext context) {
|
||||
/// 지우기·나가기 등 위험한 동작의 색 (시안 #E05A4A)
|
||||
const Color kDangerTone = Color(0xFFE05A4A);
|
||||
|
||||
/// 기록 수정 글칸 높이 (시안 minHeight 120 — 작성 화면과 같다)
|
||||
const double _kEditTextMinHeight = 120;
|
||||
|
||||
/// 기록 글 수정 — 저장하면 새 글, 닫으면 null
|
||||
Future<String?> showEditPostSheet(BuildContext context, String initial) {
|
||||
return showRupSheet<String>(
|
||||
@@ -477,8 +497,10 @@ class _EditPostBodyState extends State<_EditPostBody> {
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
RupField(
|
||||
label: '오늘 하루는 어떠셨나요?',
|
||||
child: RupTextField(controller: _text, multiline: true),
|
||||
labelGap: AppSpacing.md,
|
||||
child: RupTextField(controller: _text, multiline: true, minHeight: _kEditTextMinHeight),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
RupSheetBleedButton(
|
||||
label: '수정 완료',
|
||||
// 비우면 원래 글을 그대로 둔다 (시안)
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:tma_app/features/diary/presentation/widgets/diary_cover.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.dart';
|
||||
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
@@ -37,6 +38,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 +50,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 +64,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,8 +86,10 @@ 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);
|
||||
if (latest.isEmpty) return const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요'));
|
||||
// 추억 보관함으로 간 다이어리의 기록은 홈에 올리지 않는다
|
||||
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('아직 작성한 다이어리가 없어요', padding: 28));
|
||||
final today = dateOnly(DateTime.now());
|
||||
|
||||
return Row(
|
||||
@@ -103,7 +108,7 @@ class HomeDiaryGrid extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeDiaryTile extends StatelessWidget {
|
||||
class _HomeDiaryTile extends ConsumerWidget {
|
||||
const _HomeDiaryTile({required this.post, required this.book, required this.names, required this.today});
|
||||
|
||||
final DiaryPost post;
|
||||
@@ -112,7 +117,7 @@ class _HomeDiaryTile extends StatelessWidget {
|
||||
final DateTime today;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final diary = book.diary(post.diaryId);
|
||||
// 누구의 기록인지 — 다이어리의 첫 아이, 없으면 다이어리 이름
|
||||
final who = diary == null ? '' : (diary.targetIds.map((id) => names[id]).nonNulls.firstOrNull ?? diary.name);
|
||||
@@ -125,7 +130,10 @@ class _HomeDiaryTile extends StatelessWidget {
|
||||
label: '$who ${relativeDayLabel(post.date, today)} 기록',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(context.push(AppRoutes.diary(post.diaryId))),
|
||||
onTap: () {
|
||||
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
|
||||
context.go(AppRoutes.records);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
@@ -8,13 +8,21 @@ import 'package:tma_app/features/auth/presentation/providers/user_provider.dart'
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_shelf.dart';
|
||||
import 'package:tma_app/features/home/presentation/widgets/home_pets_section.dart';
|
||||
import 'package:tma_app/features/home/presentation/widgets/home_schedule_section.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.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/section_title.dart';
|
||||
|
||||
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (시안 .rup-screen 아래 124 에서 탭바 몫을 뺀 값)
|
||||
const double _kBottomGap = 30;
|
||||
|
||||
/// 헤더 아이콘 버튼 크기 (시안 .rup-iconbtn)
|
||||
const double _kIconButton = 42;
|
||||
|
||||
/// 새 알림 점 (시안 .rup-iconbtn__dot — 테두리 포함 8)
|
||||
const double _kUnreadDot = 8;
|
||||
|
||||
/// 홈 — 인사말, 우리 아이, 오늘 일정·예약 일정, 다이어리
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -47,6 +55,7 @@ class HomePage extends ConsumerWidget {
|
||||
title: '우리 아이',
|
||||
sub: true,
|
||||
trailing: '아이 상세보기',
|
||||
smallTrailing: true,
|
||||
onTrailingTap: () => context.go(AppRoutes.myPets),
|
||||
),
|
||||
),
|
||||
@@ -76,13 +85,14 @@ class HomePage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeHeader extends StatelessWidget {
|
||||
class _HomeHeader extends ConsumerWidget {
|
||||
const _HomeHeader({required this.nickname});
|
||||
|
||||
final String nickname;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unread = ref.watch(hasUnreadNoticeProvider);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -93,16 +103,32 @@ class _HomeHeader extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
// 알림 화면은 흐름 순서상 뒤에 만든다 — 지금은 아이콘만 둔다
|
||||
const SizedBox.square(
|
||||
dimension: 42,
|
||||
child: Center(
|
||||
child: RupIcon(
|
||||
RupIconName.bell,
|
||||
size: 23,
|
||||
stroke: 2.2,
|
||||
color: AppColors.ink2,
|
||||
semanticLabel: '알림',
|
||||
Pressable(
|
||||
pressedScale: 0.9,
|
||||
semanticLabel: unread ? '알림, 새 알림 있음' : '알림',
|
||||
onTap: () => context.push(AppRoutes.notifications),
|
||||
child: SizedBox.square(
|
||||
dimension: _kIconButton,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: RupIcon(RupIconName.bell, size: 23, stroke: 2.2, color: AppColors.ink2),
|
||||
),
|
||||
if (unread)
|
||||
Positioned(
|
||||
top: 9,
|
||||
right: 10,
|
||||
child: Container(
|
||||
width: _kUnreadDot,
|
||||
height: _kUnreadDot,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.surface, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
@@ -56,6 +56,8 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
|
||||
pets: pets,
|
||||
selectedId: selected?.id,
|
||||
onSelect: (id) => ref.read(selectedPetIdProvider.notifier).select(id),
|
||||
// 꾹 눌러 끌어서 우리 아이 순서를 바꾼다
|
||||
onReorder: (ids) => unawaited(ref.read(petListProvider.notifier).reorder(ids)),
|
||||
),
|
||||
if (selected != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@@ -99,9 +99,9 @@ class HomeScheduleSection extends ConsumerWidget {
|
||||
children: [
|
||||
_TodayProgress(done: doneCount, total: tasks.length),
|
||||
if (tasks.isEmpty)
|
||||
const EmptyMessage('오늘은 등록된 일정이 없어요')
|
||||
const EmptyMessage('오늘은 등록된 일정이 없어요', padding: 28)
|
||||
else if (!review && pending.isEmpty)
|
||||
const EmptyMessage('남은 오늘 일정이 없어요'),
|
||||
const EmptyMessage('남은 오늘 일정이 없어요', padding: 28),
|
||||
for (final (i, t) in visible.indexed)
|
||||
_TaskRow(
|
||||
event: t,
|
||||
@@ -122,7 +122,7 @@ class HomeScheduleSection extends ConsumerWidget {
|
||||
const SizedBox(height: 10),
|
||||
RupCard(
|
||||
child: upcoming.isEmpty
|
||||
? const EmptyMessage('예약된 일정이 없어요')
|
||||
? const EmptyMessage('예약된 일정이 없어요', padding: 28)
|
||||
: Column(
|
||||
children: [
|
||||
for (final (i, e) in upcoming.indexed)
|
||||
@@ -140,6 +140,9 @@ class HomeScheduleSection extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 아바타 묶음 아래 글자 여백 (시안 inline-flex 기준선)
|
||||
const double _kStackDescent = 3.5;
|
||||
|
||||
/// 바로 다음 할 일 — 연한 주황 테두리가 천천히 돈다 (.rup-ring-card)
|
||||
class _NextTaskCard extends StatelessWidget {
|
||||
const _NextTaskCard({required this.event, required this.onTap});
|
||||
@@ -161,7 +164,11 @@ class _NextTaskCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
PetStack(targetIds: d.targetIds, size: 30),
|
||||
// 시안의 아바타 묶음은 inline-flex 라 글자 아래 여백(3.5)만큼 자리가 더 생긴다
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: _kStackDescent),
|
||||
child: PetStack(targetIds: d.targetIds, size: 30),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -367,16 +374,19 @@ class _TodayProgress extends StatelessWidget {
|
||||
Text('오늘 할 일', style: label),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: total == 0 ? 0 : done / total),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, v, _) => LinearProgressIndicator(
|
||||
value: v,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.line2,
|
||||
color: AppColors.primary,
|
||||
// 시안 .rup-prog__bar — 채운 막대도 끝이 둥글다
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: total == 0 ? 0 : done / total),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, v, _) => Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: AppColors.line2, borderRadius: BorderRadius.circular(6)),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: v,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -444,8 +454,6 @@ class _TaskRow extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
d.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall!.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: _kNormal,
|
||||
@@ -456,8 +464,6 @@ class _TaskRow extends ConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal),
|
||||
),
|
||||
],
|
||||
@@ -570,8 +576,6 @@ class _UpcomingRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
d.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal),
|
||||
),
|
||||
EventMeta(details: d, avatarSize: 14, gap: 4),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:tma_app/features/diary/presentation/providers/diary_providers.da
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
import 'package:tma_app/features/pet/data/pet_photo_store.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';
|
||||
@@ -38,6 +39,7 @@ class AccountActions {
|
||||
..invalidate(carePresetsProvider)
|
||||
..invalidate(recordsTabProvider)
|
||||
..invalidate(appSettingsProvider)
|
||||
..invalidate(noticeSeenProvider)
|
||||
..invalidate(entryProvider);
|
||||
try {
|
||||
await photos.clearAll();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:tma_app/core/constants/app_assets.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/auth/presentation/providers/user_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
@@ -125,6 +126,8 @@ class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> {
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
// 시안 .rup-field 아래 16 (바닥 버튼 위 18 과 겹치지 않는다)
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
RupSheetBleedButton(label: '수정 완료', onPressed: () => unawaited(_submit())),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
|
||||
/// 알림 종류 — 다이어리 설정의 알림 스위치와 짝을 이룬다
|
||||
enum NoticeType {
|
||||
/// 가족이 새 글을 올렸다 (공유 다이어리 · 서버)
|
||||
newPost,
|
||||
|
||||
/// 내 기록에 댓글이 달렸다 (공유 다이어리 · 서버)
|
||||
newComment,
|
||||
|
||||
/// 오늘 기록을 아직 남기지 않았다 (기기에서 만든다)
|
||||
reminder,
|
||||
}
|
||||
|
||||
/// 알림 한 건
|
||||
class AppNotice {
|
||||
const AppNotice({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.diaryId,
|
||||
required this.diaryName,
|
||||
required this.at,
|
||||
required this.text,
|
||||
this.who,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final NoticeType type;
|
||||
final String diaryId;
|
||||
final String diaryName;
|
||||
final DateTime at;
|
||||
|
||||
/// 알림 문구 — 새 글·댓글이면 그 내용, 기록 알림이면 안내 문구
|
||||
final String text;
|
||||
|
||||
/// 새 글·댓글을 남긴 사람 이름
|
||||
final String? who;
|
||||
}
|
||||
|
||||
/// 알림을 묶는 날 — 오늘 · 어제 · 최근 7일
|
||||
enum NoticeDay {
|
||||
today('오늘'),
|
||||
yesterday('어제'),
|
||||
week('최근 7일');
|
||||
|
||||
const NoticeDay(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
/// [at] 이 [now] 기준 어느 묶음인지 — 일주일이 넘었으면 null(보여 주지 않는다)
|
||||
static NoticeDay? of(DateTime at, DateTime now) {
|
||||
final days = DateTime.utc(now.year, now.month, now.day).difference(DateTime.utc(at.year, at.month, at.day)).inDays;
|
||||
if (days <= 0) return NoticeDay.today;
|
||||
if (days == 1) return NoticeDay.yesterday;
|
||||
if (days < 7) return NoticeDay.week;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// "방금 전" · "12분 전" · "3시간 전" · "어제" · "4일 전"
|
||||
String relativeTimeLabel(DateTime at, DateTime now) {
|
||||
final diff = now.difference(at);
|
||||
if (diff.inMinutes < 1) return '방금 전';
|
||||
if (dateOnly(at) == dateOnly(now)) {
|
||||
return diff.inHours < 1 ? '${diff.inMinutes}분 전' : '${diff.inHours}시간 전';
|
||||
}
|
||||
return relativeDayLabel(at, now);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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/clock.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.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';
|
||||
|
||||
const double _kAvatar = 38;
|
||||
|
||||
/// 알림 — 모든 다이어리의 소식을 날짜별로 모아 본다
|
||||
class NotificationsPage extends ConsumerStatefulWidget {
|
||||
const NotificationsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotificationsPage> createState() => _NotificationsPageState();
|
||||
}
|
||||
|
||||
class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
||||
/// 들어오기 전에 마지막으로 본 시각 — 이번에 보는 동안은 새 알림 표시를 유지한다
|
||||
late final DateTime? _seenBefore = ref.read(noticeSeenProvider);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 들어온 순간 모두 읽은 것으로 — 홈 종의 점이 사라진다
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(ref.read(noticeSeenProvider.notifier).markSeen(ref.read(clockProvider)()));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notices = ref.watch(noticesProvider);
|
||||
final now = ref.watch(clockProvider)();
|
||||
final groups = [
|
||||
for (final day in NoticeDay.values)
|
||||
(day: day, items: notices.where((n) => NoticeDay.of(n.at, now) == day).toList()),
|
||||
].where((g) => g.items.isNotEmpty).toList();
|
||||
final seen = _seenBefore;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.xl,
|
||||
MediaQuery.paddingOf(context).bottom + 40,
|
||||
),
|
||||
children: [
|
||||
SubPageHeader(
|
||||
title: '알림',
|
||||
subtitle: '우리 가족 다이어리 소식',
|
||||
displayFont: true,
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
if (groups.isEmpty)
|
||||
const RupCard(child: EmptyMessage('새로운 알림이 없어요', padding: 38))
|
||||
else
|
||||
for (final g in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 9),
|
||||
child: Text(
|
||||
g.day.label,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, n) in g.items.indexed)
|
||||
_NoticeRow(
|
||||
notice: n,
|
||||
now: now,
|
||||
first: i == 0,
|
||||
unread: seen == null || n.at.isAfter(seen),
|
||||
onTap: () => unawaited(context.push(AppRoutes.diary(n.diaryId))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeRow extends StatelessWidget {
|
||||
const _NoticeRow({
|
||||
required this.notice,
|
||||
required this.now,
|
||||
required this.first,
|
||||
required this.unread,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AppNotice notice;
|
||||
final DateTime now;
|
||||
final bool first;
|
||||
final bool unread;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final n = notice;
|
||||
final who = TextSpan(text: n.who ?? '', style: const TextStyle(fontWeight: FontWeight.w700));
|
||||
final label = switch (n.type) {
|
||||
NoticeType.newPost => TextSpan(children: [who, const TextSpan(text: '님이 새 글을 올렸어요')]),
|
||||
NoticeType.newComment => TextSpan(children: [who, const TextSpan(text: '님이 회원님의 기록에 댓글을 남겼어요')]),
|
||||
NoticeType.reminder => TextSpan(text: n.text),
|
||||
};
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: unread ? AppColors.primary50 : Colors.transparent,
|
||||
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (n.who != null)
|
||||
const PersonAvatar(size: _kAvatar)
|
||||
else
|
||||
Container(
|
||||
width: _kAvatar,
|
||||
height: _kAvatar,
|
||||
decoration: BoxDecoration(color: AppColors.accent.withValues(alpha: .16), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.paw, size: 20, stroke: 2, color: AppColors.accent),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(label, style: textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.4, color: AppColors.ink)),
|
||||
if (n.type != NoticeType.reminder && n.text.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'“${n.text}”',
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, height: 1.4, color: AppColors.ink2),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${n.diaryName} · ${relativeTimeLabel(n.at, now)}',
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (unread) ...[
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/core/utils/date_format.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/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
|
||||
const String _kSeenKey = 'notice.seenAt.v1';
|
||||
|
||||
/// 기록 알림 — 이 시각까지 오늘 기록이 없으면 알린다 (저녁 8시)
|
||||
const int kDiaryReminderHour = 20;
|
||||
|
||||
/// 기록 알림 문구
|
||||
const String kDiaryReminderText = '오늘 기록을 아직 남기지 않았어요. 잊기 전에 남겨 볼까요?';
|
||||
|
||||
/// 알림 화면을 마지막으로 본 시각 — 이후에 생긴 알림이 "새 알림"
|
||||
class NoticeSeenNotifier extends Notifier<DateTime?> {
|
||||
@override
|
||||
DateTime? build() => DateTime.tryParse(ref.watch(sharedPreferencesProvider).getString(_kSeenKey) ?? '');
|
||||
|
||||
Future<void> markSeen(DateTime at) async {
|
||||
state = at;
|
||||
await ref.read(sharedPreferencesProvider).setString(_kSeenKey, at.toIso8601String());
|
||||
}
|
||||
}
|
||||
|
||||
final noticeSeenProvider = NotifierProvider<NoticeSeenNotifier, DateTime?>(NoticeSeenNotifier.new);
|
||||
|
||||
/// 모든 다이어리의 알림 — 최근 것이 위로.
|
||||
/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다.
|
||||
final noticesProvider = Provider<List<AppNotice>>((ref) => _watchNotices(ref));
|
||||
|
||||
/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고)
|
||||
List<AppNotice> _watchNotices(Ref ref) {
|
||||
final now = ref.watch(clockProvider)();
|
||||
// 저녁 8시·자정에 다시 계산한다 — 앱을 켜 둔 채 시각이 지나도 기록 알림이 맞게 나타나고 사라진다
|
||||
final next = now.hour < kDiaryReminderHour
|
||||
? DateTime(now.year, now.month, now.day, kDiaryReminderHour)
|
||||
: DateTime(now.year, now.month, now.day + 1);
|
||||
final timer = Timer(next.difference(now), ref.invalidateSelf);
|
||||
ref.onDispose(timer.cancel);
|
||||
|
||||
// 마이페이지 "일정·다이어리 알림" 을 끄면 모두 끈다
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
if (settings[AppSetting.scheduleNotification] == false) return const [];
|
||||
|
||||
final book = ref.watch(diaryProvider).value ?? DiaryBook.empty;
|
||||
final notices = <AppNotice>[];
|
||||
if (now.hour >= kDiaryReminderHour) {
|
||||
final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour);
|
||||
// 추억 보관함으로 간 다이어리는 알리지 않는다
|
||||
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)}',
|
||||
type: NoticeType.reminder,
|
||||
diaryId: d.id,
|
||||
diaryName: d.name,
|
||||
at: at,
|
||||
text: kDiaryReminderText,
|
||||
));
|
||||
}
|
||||
}
|
||||
return notices..sort((a, b) => b.at.compareTo(a.at));
|
||||
}
|
||||
|
||||
/// 아직 보지 않은 알림이 있는지 — 홈 종에 점을 찍는다
|
||||
final hasUnreadNoticeProvider = Provider<bool>((ref) {
|
||||
final seen = ref.watch(noticeSeenProvider);
|
||||
return _watchNotices(ref).any((n) => seen == null || n.at.isAfter(seen));
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/onboarding/presentation/widgets/onboarding_scene.dart';
|
||||
import 'package:tma_app/shared/widgets/design_art_box.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
|
||||
/// 온보딩 CTA 높이 — 시안 onb-4 의 버튼(372×42, rx 21)
|
||||
@@ -59,11 +58,8 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DesignCover(
|
||||
designSize: kOnboardingCanvas,
|
||||
// 장이 바뀌면 등장 애니메이션을 처음부터 다시 재생한다
|
||||
child: OnboardingScene(key: ValueKey(slide), slide: slide),
|
||||
),
|
||||
// 장이 바뀌면 등장 애니메이션을 처음부터 다시 재생한다
|
||||
_OnboardingArt(child: OnboardingScene(key: ValueKey(slide), slide: slide)),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -119,3 +115,39 @@ class _Hint extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 시안 .rup-onb__art — 402 폭 기기에서 414×897 그림을 (-6, -11) 에 놓는다.
|
||||
/// 폭에 맞춰 같은 비율로 키우고, 키가 큰 화면에서 아래가 비면 그만큼 더 키워 가운데 둔다
|
||||
class _OnboardingArt extends StatelessWidget {
|
||||
const _OnboardingArt({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
static const double _deviceWidth = 402;
|
||||
static const Rect _art = Rect.fromLTWH(-6, -11, 414, 897);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final unit = box.maxWidth / _deviceWidth;
|
||||
var rect = Rect.fromLTWH(_art.left * unit, _art.top * unit, _art.width * unit, _art.height * unit);
|
||||
if (rect.bottom < box.maxHeight) {
|
||||
final grow = (box.maxHeight - rect.top) / rect.height;
|
||||
final width = rect.width * grow;
|
||||
rect = Rect.fromLTWH((box.maxWidth - width) / 2, rect.top, width, rect.height * grow);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fromRect(
|
||||
rect: rect,
|
||||
child: FittedBox(fit: BoxFit.fill, child: SizedBox.fromSize(size: kOnboardingCanvas, child: child)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 @@ 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);
|
||||
|
||||
/// 순서 바꾸기 — [ids] 순서로 두고, 빠진 아이(추억 보관함 등)는 뒤에 그대로 둔다
|
||||
Future<List<Pet>> reorder(List<String> ids);
|
||||
}
|
||||
|
||||
const String _kPetsKey = 'pets.v1';
|
||||
@@ -55,9 +65,29 @@ 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);
|
||||
|
||||
@override
|
||||
Future<List<Pet>> reorder(List<String> ids) async {
|
||||
final pets = reorderPets(_read(), ids);
|
||||
await _write(pets);
|
||||
return pets;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -68,6 +98,15 @@ class LocalPetRepository implements PetRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// [ids] 순서로 줄 세운 목록 — 빠진 아이는 원래 순서대로 뒤에 붙인다 (시안 reorderPets)
|
||||
List<Pet> reorderPets(List<Pet> pets, List<String> ids) {
|
||||
final byId = {for (final p in pets) p.id: p};
|
||||
return [
|
||||
for (final id in ids) ?byId[id],
|
||||
for (final p in pets) if (!ids.contains(p.id)) p,
|
||||
];
|
||||
}
|
||||
|
||||
final petRepositoryProvider = Provider<PetRepository>((ref) {
|
||||
return LocalPetRepository(ref.watch(sharedPreferencesProvider));
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -26,12 +27,10 @@ import 'package:tma_app/shared/widgets/tab_sub_page.dart';
|
||||
const double _kPhotoSize = 76;
|
||||
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);
|
||||
/// 사진이 없을 때 칸 바탕 (시안 image-slot rgba(0,0,0,.04))
|
||||
const Color _kEmptyPhotoFill = Color(0x0A000000);
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 반려동물 정보 — 기본·건강 정보를 읽기 전용으로 보여 주고, 연필을 누르면 수정
|
||||
class PetInfoPage extends ConsumerWidget {
|
||||
@@ -55,7 +54,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: [
|
||||
@@ -63,7 +61,7 @@ class PetInfoPage extends ConsumerWidget {
|
||||
child: SubPageHeader(
|
||||
title: '반려동물 정보',
|
||||
subtitle: '${pet.name}의 상세 정보',
|
||||
onBack: () => context.pop(),
|
||||
onBack: () => context.go(AppRoutes.myPage),
|
||||
trailing: Pressable(
|
||||
onTap: () => unawaited(context.push(AppRoutes.petEdit(pet.id))),
|
||||
pressedScale: .9,
|
||||
@@ -79,40 +77,71 @@ class PetInfoPage extends ConsumerWidget {
|
||||
child: _IdentityCard(
|
||||
pet: pet,
|
||||
cardCount: cardCount,
|
||||
onCardTap: () => unawaited(
|
||||
context.push(cardCount == 0 ? AppRoutes.petCardCreate(pet.id) : AppRoutes.petCardManage),
|
||||
),
|
||||
onCardTap: () => cardCount == 0
|
||||
? unawaited(context.push(AppRoutes.petCardManage))
|
||||
: context.go(AppRoutes.petCards),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사진 · 이름 · 종 · 명함 바로가기
|
||||
@@ -135,6 +164,7 @@ class _IdentityCard extends StatelessWidget {
|
||||
width: _kPhotoSize,
|
||||
height: _kPhotoSize,
|
||||
decoration: BoxDecoration(
|
||||
color: photo == null ? _kEmptyPhotoFill : null,
|
||||
borderRadius: BorderRadius.circular(_kPhotoRadius),
|
||||
border: Border.all(color: _kPhotoBorder, width: 1.5),
|
||||
),
|
||||
@@ -152,7 +182,13 @@ class _IdentityCard extends StatelessWidget {
|
||||
pet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontFamily: AppFonts.display, fontSize: 22, height: 1, color: AppColors.ink),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 22,
|
||||
height: 1,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
@@ -200,164 +236,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.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/core/utils/korean.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
@@ -244,6 +245,7 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
|
||||
if (mounted) context.go(AppRoutes.myPets);
|
||||
} else {
|
||||
await notifier.updatePet(editing.id, _buildInput());
|
||||
ref.read(selectedPetIdProvider.notifier).select(editing.id);
|
||||
toast.show('반려동물 정보를 저장했어요');
|
||||
// 수정은 반려동물 정보에서 열었으므로 그 화면으로 돌아간다
|
||||
if (mounted) context.pop();
|
||||
@@ -300,8 +302,8 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
// 시안: 사진 아래 4 는 "기본 정보" 위 30 과 겹쳐 사라진다
|
||||
PetPhotoSlot(photoPath: _photoPath, onPick: _pickPhoto),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
const PetFormSectionLabel(text: '기본 정보', required: true),
|
||||
RupField(
|
||||
@@ -468,13 +470,22 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
|
||||
if (editing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 22),
|
||||
child: TextButton(
|
||||
onPressed: _delete,
|
||||
child: Text(
|
||||
'삭제하기',
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _delete,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Text(
|
||||
'삭제하기',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,43 @@ 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> reorder(List<String> ids) async {
|
||||
state = AsyncData(reorderPets(state.value ?? const <Pet>[], ids));
|
||||
await _repo.reorder(ids);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
/// 보관된 아이 id — 다이어리를 가를 때
|
||||
Set<String> get archivedIds => {for (final p in this) if (p.isArchived) p.id};
|
||||
}
|
||||
|
||||
/// 홈에서 고른 아이 id
|
||||
class SelectedPetIdNotifier extends Notifier<String?> {
|
||||
@override
|
||||
@@ -45,14 +79,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;
|
||||
|
||||
@@ -8,6 +8,9 @@ import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
|
||||
/// 사진이 없을 때 원 테두리 — 시안 .rup-avatar--slot
|
||||
const Color _kEmptyRing = Color(0xFFC8C8C8);
|
||||
|
||||
/// 사진이 없을 때 원 바탕 — 시안 image-slot 의 rgba(0,0,0,.04)
|
||||
const Color _kEmptyFill = Color(0x0A000000);
|
||||
|
||||
/// [PetAvatar.ring] — 흰 틈 2.5 + 아이 색 고리 1.5 (시안 box-shadow 0 0 0 2.5px surface, 0 0 0 4px tint)
|
||||
const double _kRingGap = 2.5;
|
||||
const double _kRingOuter = 4;
|
||||
@@ -33,6 +36,7 @@ class PetAvatar extends StatelessWidget {
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: photo == null ? _kEmptyFill : null,
|
||||
border: photo == null ? Border.all(color: _kEmptyRing, width: 1.5) : null,
|
||||
// 뒤에 오는 그림자가 위에 그려진다 — 색 고리 위에 흰 틈을 얹는다
|
||||
boxShadow: ring
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,12 +219,15 @@ class _BleedList extends StatelessWidget {
|
||||
|
||||
/// 선택지 한 줄 — 고른 항목은 연주황 바탕 + 체크
|
||||
class _ChoiceRow extends StatelessWidget {
|
||||
const _ChoiceRow({required this.label, required this.selected, required this.onTap});
|
||||
const _ChoiceRow({required this.label, required this.selected, required this.onTap, this.vertical = 13});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// 세로 여백 — 종 선택 13, 몸무게 단위 14
|
||||
final double vertical;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
@@ -236,7 +239,7 @@ class _ChoiceRow extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14, vertical: vertical),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.primary50 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -280,6 +283,7 @@ Future<WeightUnit?> showWeightUnitPicker(BuildContext context, {required WeightU
|
||||
_ChoiceRow(
|
||||
label: unit.label,
|
||||
selected: unit == current,
|
||||
vertical: 14,
|
||||
onTap: () => Navigator.of(context).pop(unit),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
@@ -9,65 +10,187 @@ const double _kRingSize = 58;
|
||||
const double _kAvatarSize = 46;
|
||||
const double _kRingBorder = 2.5;
|
||||
|
||||
/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리
|
||||
class PetPicker extends StatelessWidget {
|
||||
const PetPicker({super.key, required this.pets, required this.selectedId, required this.onSelect});
|
||||
/// 꾹 눌러 순서 바꾸기 — 시안: 320ms 누르면 들어 올리고, 1.12 배로 손가락을 따라온다
|
||||
const Duration _kHoldDelay = Duration(milliseconds: 320);
|
||||
const double _kLiftScale = 1.12;
|
||||
|
||||
/// 들어 올린 아이 그림자 (시안 drop-shadow(0 8px 14px rgba(0,0,0,.22)))
|
||||
const List<BoxShadow> _kLiftShadow = [BoxShadow(color: Color(0x38000000), offset: Offset(0, 8), blurRadius: 14)];
|
||||
const List<Shadow> _kLiftTextShadow = [Shadow(color: Color(0x38000000), offset: Offset(0, 8), blurRadius: 14)];
|
||||
|
||||
/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리.
|
||||
/// [onReorder] 가 있으면 꾹 눌러 끌어서 순서를 바꾼다 (홈 우리 아이).
|
||||
class PetPicker extends StatefulWidget {
|
||||
const PetPicker({
|
||||
super.key,
|
||||
required this.pets,
|
||||
required this.selectedId,
|
||||
required this.onSelect,
|
||||
this.onReorder,
|
||||
});
|
||||
|
||||
final List<Pet> pets;
|
||||
final String? selectedId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
/// 끌어서 바꾼 순서 (아이 id 목록)
|
||||
final ValueChanged<List<String>>? onReorder;
|
||||
|
||||
@override
|
||||
State<PetPicker> createState() => _PetPickerState();
|
||||
}
|
||||
|
||||
class _PetPickerState extends State<PetPicker> {
|
||||
/// 끄는 동안의 임시 순서 — 평소에는 null
|
||||
List<String>? _order;
|
||||
String? _dragId;
|
||||
|
||||
List<Pet> get _shown {
|
||||
final order = _order;
|
||||
if (order == null) return widget.pets;
|
||||
final byId = {for (final p in widget.pets) p.id: p};
|
||||
return [for (final id in order) ?byId[id]];
|
||||
}
|
||||
|
||||
void _start(String id) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_order = [for (final p in widget.pets) p.id];
|
||||
_dragId = id;
|
||||
});
|
||||
}
|
||||
|
||||
/// 끌고 있는 아이가 다른 아이 위로 가면 그 자리로 옮긴다
|
||||
void _moveOver(String overId) {
|
||||
final order = _order;
|
||||
final dragId = _dragId;
|
||||
if (order == null || dragId == null || overId == dragId) return;
|
||||
final to = order.indexOf(overId);
|
||||
setState(() {
|
||||
order
|
||||
..remove(dragId)
|
||||
..insert(to, dragId);
|
||||
});
|
||||
}
|
||||
|
||||
void _end() {
|
||||
final order = _order;
|
||||
final before = [for (final p in widget.pets) p.id];
|
||||
setState(() {
|
||||
_order = null;
|
||||
_dragId = null;
|
||||
});
|
||||
if (order != null && order.join() != before.join()) widget.onReorder?.call(order);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final reorderable = widget.onReorder != null && widget.pets.length > 1;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
// 시안: 목록 칸(화면 여백 안쪽)에서 잘린다
|
||||
clipBehavior: Clip.hardEdge,
|
||||
padding: const EdgeInsets.fromLTRB(2, AppSpacing.xs, 2, AppSpacing.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (i, pet) in pets.indexed) ...[
|
||||
for (final (i, pet) in _shown.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 14),
|
||||
Semantics(
|
||||
button: true,
|
||||
selected: pet.id == selectedId,
|
||||
label: pet.name,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelect(pet.id),
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: AppDurations.fast,
|
||||
width: _kRingSize,
|
||||
height: _kRingSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: pet.id == selectedId ? AppColors.primary : Colors.transparent,
|
||||
width: _kRingBorder,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: PetAvatar(pet: pet, size: _kAvatarSize),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
pet.name,
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 12.5,
|
||||
fontWeight: pet.id == selectedId ? FontWeight.w700 : FontWeight.w600,
|
||||
color: pet.id == selectedId ? AppColors.primary600 : AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (reorderable) _draggable(pet) else _chip(pet),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(Pet pet, {bool lifted = false}) {
|
||||
return _PetChip(
|
||||
pet: pet,
|
||||
selected: pet.id == widget.selectedId,
|
||||
lifted: lifted,
|
||||
onTap: () => widget.onSelect(pet.id),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _draggable(Pet pet) {
|
||||
return DragTarget<String>(
|
||||
onWillAcceptWithDetails: (details) {
|
||||
_moveOver(pet.id);
|
||||
return true;
|
||||
},
|
||||
builder: (context, candidates, rejected) => LongPressDraggable<String>(
|
||||
data: pet.id,
|
||||
delay: _kHoldDelay,
|
||||
hapticFeedbackOnStart: false,
|
||||
// 들어 올린 아이는 손가락 한가운데에 붙는다
|
||||
dragAnchorStrategy: pointerDragAnchorStrategy,
|
||||
feedback: FractionalTranslation(
|
||||
translation: const Offset(-.5, -.5),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Transform.scale(scale: _kLiftScale, child: _chip(pet, lifted: true)),
|
||||
),
|
||||
),
|
||||
// 빈 자리는 남겨 둔다 — 다른 아이가 그 자리로 비켜 준다
|
||||
childWhenDragging: Opacity(opacity: 0, child: _chip(pet)),
|
||||
onDragStarted: () => _start(pet.id),
|
||||
onDragEnd: (_) => _end(),
|
||||
child: _chip(pet),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PetChip extends StatelessWidget {
|
||||
const _PetChip({required this.pet, required this.selected, required this.onTap, this.lifted = false});
|
||||
|
||||
final Pet pet;
|
||||
final bool selected;
|
||||
final bool lifted;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: pet.name,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: AppDurations.fast,
|
||||
width: _kRingSize,
|
||||
height: _kRingSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: selected ? AppColors.primary : Colors.transparent, width: _kRingBorder),
|
||||
// 시안 drop-shadow 는 그려진 모양을 따른다 — 고리는 고른 아이만 보인다
|
||||
boxShadow: lifted && selected ? _kLiftShadow : null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, boxShadow: lifted ? _kLiftShadow : null),
|
||||
child: PetAvatar(pet: pet, size: _kAvatarSize),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
pet.name,
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 12.5,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
color: selected ? AppColors.primary600 : AppColors.muted,
|
||||
shadows: lifted ? _kLiftTextShadow : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,12 @@ class CardStyle {
|
||||
this.scrimTone = ScrimTone.light,
|
||||
});
|
||||
|
||||
/// 새 명함의 기본값 — 첫 번째 헤더색(코랄), 흰 배경
|
||||
/// 기본값 — 첫 번째 헤더색(코랄), 흰 배경
|
||||
factory CardStyle.initial() => CardStyle(tint: kCardTints.first.a, tint2: kCardTints.first.b);
|
||||
|
||||
/// 새 명함 — 그 아이의 고유색 한 가지로 시작한다 (시안: tint = tint2 = pet.tint → 직접 선택 칸이 켜진다)
|
||||
factory CardStyle.forPet(Color petTint) => CardStyle(tint: petTint, tint2: petTint);
|
||||
|
||||
/// 카드 상단 그라데이션 두 색
|
||||
final Color tint;
|
||||
final Color tint2;
|
||||
|
||||
@@ -68,6 +68,12 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
|
||||
toast.show('대표 명함으로 설정했어요', icon: RupIconName.star);
|
||||
}
|
||||
|
||||
/// 꾸미기 — 저장하고 돌아오면 상세히로 보여 준다 (시안 onSave → setMode('detail'))
|
||||
Future<void> _edit(PetCard card) async {
|
||||
final saved = await context.push<bool>(AppRoutes.petCardEdit(card.id));
|
||||
if (saved == true && mounted) setState(() => _mode = CardViewMode.detail);
|
||||
}
|
||||
|
||||
Future<void> _delete(PetCard card) async {
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
@@ -111,6 +117,8 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
|
||||
title: pet.name,
|
||||
subtitle: '펫 명함',
|
||||
displayFont: true,
|
||||
// 시안 상세 머리 margin '4px 0 16px'
|
||||
bottomGap: 16,
|
||||
onBack: () => context.pop(),
|
||||
trailing: SegToggle<CardViewMode>(
|
||||
first: (CardViewMode.simple, CardViewMode.simple.label),
|
||||
@@ -146,7 +154,7 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
|
||||
child: _TextAction(
|
||||
label: '카드 수정',
|
||||
color: AppColors.ink2,
|
||||
onTap: () => unawaited(context.push(AppRoutes.petCardEdit(card.id))),
|
||||
onTap: () => unawaited(_edit(card)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 1, height: 13, child: ColoredBox(color: AppColors.line)),
|
||||
@@ -236,7 +244,11 @@ class _TextAction extends StatelessWidget {
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, color: color),
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -74,7 +74,12 @@ class _CardEditor extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _CardEditorState extends ConsumerState<_CardEditor> {
|
||||
late CardStyle _style = widget.card?.style ?? CardStyle.initial();
|
||||
late CardStyle _style = widget.card?.style ?? _newStyle();
|
||||
|
||||
CardStyle _newStyle() {
|
||||
final tint = ref.read(petByIdProvider(widget.petId))?.tint;
|
||||
return tint == null ? CardStyle.initial() : CardStyle.forPet(tint);
|
||||
}
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isEdit => widget.card != null;
|
||||
@@ -138,7 +143,8 @@ class _CardEditorState extends ConsumerState<_CardEditor> {
|
||||
if (_isEdit) {
|
||||
await notifier.updateStyle(widget.card!.id, _style);
|
||||
toast.show('펫 명함을 수정했어요');
|
||||
if (mounted) context.pop();
|
||||
// 저장했음을 알려 상세 화면이 "상세히" 로 돌아가게 한다
|
||||
if (mounted) context.pop(true);
|
||||
} else {
|
||||
final card = await notifier.create(petId, _style);
|
||||
toast.show('펫 명함을 만들었어요');
|
||||
|
||||
@@ -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;
|
||||
@@ -58,7 +58,7 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.xl,
|
||||
MediaQuery.paddingOf(context).bottom + 40,
|
||||
),
|
||||
@@ -73,8 +73,10 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
|
||||
onPressed: () => unawaited(_create(pets, cards.length)),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: _kChipHeight,
|
||||
// 시안 .rup-petbar { padding: 2px 0 6px }
|
||||
Container(
|
||||
height: _kChipHeight + 8,
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
|
||||
@@ -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) =>
|
||||
@@ -67,7 +67,7 @@ class _PetCardsPageState extends ConsumerState<PetCardsPage> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (pet == null)
|
||||
const EmptyMessage('등록된 반려동물이 없어요', padding: 40)
|
||||
const EmptyMessage('등록된 반려동물이 없어요')
|
||||
else ...[
|
||||
padded(PetPicker(pets: pets, selectedId: pet.id, onSelect: (id) => setState(() => _selectedPetId = id))),
|
||||
const SizedBox(height: 14),
|
||||
@@ -195,6 +195,8 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 시안 .rup-section-title — 구분 띠 아래 22 와 제목 위 26 이 겹쳐 26 (좌우 4 는 줄 안에서)
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -234,7 +236,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
const SizedBox(width: 5),
|
||||
Pressable(
|
||||
pressedScale: .96,
|
||||
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.bell),
|
||||
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.qrcode),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
@@ -252,9 +254,11 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 시안: 제목 줄 아래 12 + 목록 위 2
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
|
||||
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
|
||||
|
||||
@@ -77,20 +77,29 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
|
||||
|
||||
final petCardsProvider = AsyncNotifierProvider<PetCardNotifier, PetCardState>(PetCardNotifier.new);
|
||||
|
||||
/// 지금 등록된 아이들의 명함만 (지워진 아이의 명함은 숨긴다)
|
||||
final myCardsProvider = Provider<List<PetCard>>((ref) {
|
||||
/// 함께 지내는 아이들의 명함만 (지워진 아이·추억 보관함의 아이 명함은 숨긴다)
|
||||
final myCardsProvider = Provider<List<PetCard>>((ref) => _watchMyCards(ref));
|
||||
|
||||
/// 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 (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();
|
||||
});
|
||||
}
|
||||
|
||||
final cardByIdProvider = Provider.family<PetCard?, String>((ref, cardId) {
|
||||
return ref.watch(myCardsProvider).where((c) => c.id == cardId).firstOrNull;
|
||||
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 = ref.watch(myCardsProvider).where((c) => c.petId == petId).toList();
|
||||
final cards = _watchMyCards(ref).where((c) => c.petId == petId).toList();
|
||||
final repId = ref.watch(petCardsProvider).value?.representatives[petId];
|
||||
return cards.where((c) => c.id == repId).firstOrNull ?? cards.firstOrNull;
|
||||
});
|
||||
|
||||
@@ -153,8 +153,9 @@ class BackgroundColorRow extends StatelessWidget {
|
||||
_Swatch(
|
||||
label: '기본',
|
||||
onTap: () => onChanged(style.withBackgroundColor(null)),
|
||||
decoration: _swatchDecoration(selected: isDefault, color: Colors.white),
|
||||
child: _DefaultLabel(selected: isDefault),
|
||||
// 시안: '기본' 칸은 골라도 그림자가 없다
|
||||
decoration: _swatchDecoration(selected: isDefault, color: Colors.white).copyWith(boxShadow: []),
|
||||
child: _DefaultLabel(selected: isDefault, fontSize: 11),
|
||||
),
|
||||
for (final b in kCardBackgrounds)
|
||||
_Swatch(
|
||||
@@ -176,16 +177,19 @@ class BackgroundColorRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DefaultLabel extends StatelessWidget {
|
||||
const _DefaultLabel({required this.selected});
|
||||
const _DefaultLabel({required this.selected, required this.fontSize});
|
||||
|
||||
final bool selected;
|
||||
|
||||
/// 시안: 색 줄 11, 이미지 줄 11.5
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
'기본',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 11.5,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected ? AppColors.primary : AppColors.muted,
|
||||
),
|
||||
@@ -217,8 +221,13 @@ class BackgroundImageGrid extends StatelessWidget {
|
||||
_ImageTile(
|
||||
label: '기본',
|
||||
selected: isDefault,
|
||||
shadow: false,
|
||||
onTap: () => onChanged(style.withBackgroundImage(null)),
|
||||
child: Container(color: Colors.white, alignment: Alignment.center, child: _DefaultLabel(selected: isDefault)),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
alignment: Alignment.center,
|
||||
child: _DefaultLabel(selected: isDefault, fontSize: 11.5),
|
||||
),
|
||||
),
|
||||
for (final (i, path) in AppAssets.cardBackgrounds.indexed)
|
||||
_ImageTile(
|
||||
@@ -275,6 +284,7 @@ class _ImageTile extends StatelessWidget {
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
this.onRemove,
|
||||
this.shadow = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
@@ -283,6 +293,9 @@ class _ImageTile extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
/// 골랐을 때 주황 그림자 — '기본' 칸은 없다
|
||||
final bool shadow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
@@ -299,7 +312,7 @@ class _ImageTile extends StatelessWidget {
|
||||
foregroundDecoration: _swatchDecoration(selected: selected).copyWith(boxShadow: []),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_kSwatchRadius),
|
||||
boxShadow: selected
|
||||
boxShadow: selected && shadow
|
||||
? [BoxShadow(color: AppColors.primary.withValues(alpha: .3), offset: const Offset(0, 2), blurRadius: 8)]
|
||||
: null,
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/card_background_image.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
|
||||
/// 성별 표시 색 — 시안 GENDER
|
||||
const Map<PetGender, Color> kGenderColors = {
|
||||
@@ -237,42 +238,46 @@ class _Body extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
pet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
// 시안: 충북대체 일반 굵기 + 0.7px 외곽선 → 굵게 보인다
|
||||
fontFamily: AppFonts.displayRegular,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
height: 1,
|
||||
color: ink.strong,
|
||||
),
|
||||
// 이름·성별 묶음이 남은 폭을 모두 차지해 공유 버튼을 오른쪽 끝으로 민다 (시안 flex:1 빈칸)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
pet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
// 시안: 충북대체 일반 굵기 + 0.7px 외곽선 → 굵게 보인다
|
||||
fontFamily: AppFonts.displayRegular,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
height: 1,
|
||||
color: ink.strong,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: gender.withValues(alpha: .12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
pet.gender.label,
|
||||
style: small.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: gender),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: gender.withValues(alpha: .12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
pet.gender.label,
|
||||
style: small.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: gender),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (onShare != null)
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '${pet.name} 명함 공유',
|
||||
child: GestureDetector(
|
||||
onTap: onShare,
|
||||
child: SvgPicture.string(_kShareGlyph, width: 32, height: 32),
|
||||
),
|
||||
Pressable(
|
||||
onTap: onShare,
|
||||
pressedScale: .92,
|
||||
semanticLabel: '${pet.name} 명함 공유',
|
||||
child: SvgPicture.string(_kShareGlyph, width: 32, height: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -12,15 +12,31 @@ import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
const double _kMinHeight = 193;
|
||||
const double _kRadius = 18;
|
||||
|
||||
/// 누르는 동안 바탕 (시안 .rup-namecard-empty:active)
|
||||
const Color _kPressedBg = Color(0xFFFCF7F2);
|
||||
|
||||
/// 아직 펫 명함이 없는 아이 — 이름 + 안내 + "명함 만들기"
|
||||
class NameCardEmpty extends StatelessWidget {
|
||||
class NameCardEmpty extends StatefulWidget {
|
||||
const NameCardEmpty({super.key, required this.petName, required this.onCreate});
|
||||
|
||||
final String petName;
|
||||
final VoidCallback onCreate;
|
||||
|
||||
@override
|
||||
State<NameCardEmpty> createState() => _NameCardEmptyState();
|
||||
}
|
||||
|
||||
class _NameCardEmptyState extends State<NameCardEmpty> {
|
||||
bool _pressed = false;
|
||||
|
||||
void _press(bool v) {
|
||||
if (_pressed != v) setState(() => _pressed = v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final petName = widget.petName;
|
||||
final onCreate = widget.onCreate;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Semantics(
|
||||
button: true,
|
||||
@@ -29,10 +45,13 @@ class NameCardEmpty extends StatelessWidget {
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onCreate,
|
||||
onTapDown: (_) => _press(true),
|
||||
onTapUp: (_) => _press(false),
|
||||
onTapCancel: () => _press(false),
|
||||
child: DashedBorderBox(
|
||||
color: AppColors.emptyCardBorder,
|
||||
radius: _kRadius,
|
||||
backgroundColor: AppColors.emptyCardBg,
|
||||
backgroundColor: _pressed ? _kPressedBg : AppColors.emptyCardBg,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: _kMinHeight, minWidth: double.infinity),
|
||||
child: Padding(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.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/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
|
||||
@@ -83,6 +84,7 @@ class PetIdCard extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -30,7 +30,7 @@ Future<String?> showPickPetSheet(BuildContext context, {required List<Pet> pets}
|
||||
Text(
|
||||
'어떤 반려동물의 카드를 만들까요?\n등록된 정보로 카드가 자동 채워져요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5),
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: AppTypography.normalHeight),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
for (final (i, p) in pets.indexed) ...[
|
||||
|
||||
@@ -4,13 +4,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/storage/album_saver.dart';
|
||||
import 'package:tma_app/core/theme/app_shadows.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
|
||||
import 'package:tma_app/shared/widgets/blurred_modal.dart';
|
||||
import 'package:tma_app/shared/widgets/capture_boundary.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_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_sheet.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
@@ -42,10 +44,24 @@ Future<void> showPetCardShareSheet(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sharePng(Uint8List bytes, String fileName) async {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: fileName)]),
|
||||
);
|
||||
/// 앨범에 저장하지 못했을 때 (권한 거절 등)
|
||||
const String _kSaveFailed = '저장하지 못했어요. 사진 접근 권한을 확인해 주세요';
|
||||
|
||||
/// 앨범에 저장하고 결과를 알린다 — 시안: 시트를 닫고 "…저장했어요".
|
||||
/// 시트를 닫은 뒤에 불리므로 저장기·토스트는 닫기 전에 꺼내 넘긴다
|
||||
Future<void> _saveToAlbum(
|
||||
AlbumSaver saver,
|
||||
ToastNotifier toast,
|
||||
Uint8List? bytes,
|
||||
String name,
|
||||
String done,
|
||||
) async {
|
||||
final saved = bytes != null && await saver.savePng(bytes, name);
|
||||
if (saved) {
|
||||
toast.show(done, icon: RupIconName.download);
|
||||
} else {
|
||||
toast.show(_kSaveFailed, icon: RupIconName.alert);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareBody extends ConsumerWidget {
|
||||
@@ -61,16 +77,18 @@ class _ShareBody extends ConsumerWidget {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
|
||||
final saver = ref.read(albumSaverProvider);
|
||||
|
||||
Future<void> saveImage() async {
|
||||
final bytes = await capture();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (bytes != null) await _sharePng(bytes, 'rup_${petName}_명함.png');
|
||||
await _saveToAlbum(saver, toast, bytes, 'rup_${petName}_명함', '이미지로 저장했어요');
|
||||
}
|
||||
|
||||
Future<void> copyCode() async {
|
||||
await Clipboard.setData(ClipboardData(text: code));
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
toast.show('명함 코드를 복사했어요', icon: RupIconName.link);
|
||||
toast.show('코드 $code 을 복사했어요.', icon: RupIconName.link);
|
||||
}
|
||||
|
||||
return Column(
|
||||
@@ -138,7 +156,7 @@ class _ShareBody extends ConsumerWidget {
|
||||
label: 'QR 코드\n공유',
|
||||
desc: '스캔 한 번',
|
||||
tint: const Color(0xFF9B6BE8),
|
||||
onTap: () => unawaited(_showQr(context, petName: petName, code: code)),
|
||||
onTap: () => unawaited(_showQr(context, saver, toast, petName: petName, code: code)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -166,48 +184,45 @@ class _ShareTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label.replaceAll('\n', ' '),
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(5, 20, 5, 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: tint.withValues(alpha: .22)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: tint,
|
||||
borderRadius: BorderRadius.circular(17),
|
||||
boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon == RupIconName.qrcode
|
||||
? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white)
|
||||
: RupIcon(icon, size: 26, stroke: 2, color: Colors.white),
|
||||
// 시안 .rup-share-tile:active — scale(.95)
|
||||
return Pressable(
|
||||
onTap: onTap,
|
||||
semanticLabel: label.replaceAll('\n', ' '),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(5, 20, 5, 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: tint.withValues(alpha: .22)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: tint,
|
||||
borderRadius: BorderRadius.circular(17),
|
||||
boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)],
|
||||
),
|
||||
const SizedBox(height: 17),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
desc,
|
||||
maxLines: 1,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon == RupIconName.qrcode
|
||||
? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white)
|
||||
: RupIcon(icon, size: 26, stroke: 2, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 17),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
desc,
|
||||
maxLines: 1,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -215,12 +230,17 @@ class _ShareTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 펫 명함 QR — 가운데 명함 표시가 있는 QR + 이미지 저장
|
||||
Future<void> _showQr(BuildContext context, {required String petName, required String code}) {
|
||||
Future<void> _showQr(
|
||||
BuildContext context,
|
||||
AlbumSaver saver,
|
||||
ToastNotifier toast, {
|
||||
required String petName,
|
||||
required String code,
|
||||
}) {
|
||||
final capture = CaptureController();
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierColor: AppColors.scrim,
|
||||
final sheet = Navigator.of(context);
|
||||
return showBlurredDialog<void>(
|
||||
context,
|
||||
builder: (context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Dialog(
|
||||
@@ -234,6 +254,7 @@ Future<void> _showQr(BuildContext context, {required String petName, required St
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
border: Border.all(color: AppColors.line2),
|
||||
boxShadow: AppShadows.sm,
|
||||
),
|
||||
child: Stack(
|
||||
@@ -299,8 +320,10 @@ Future<void> _showQr(BuildContext context, {required String petName, required St
|
||||
leading: const RupIcon(RupIconName.download, size: 18, stroke: 2.1, color: Colors.white),
|
||||
onPressed: () async {
|
||||
final bytes = await capture.capturePng();
|
||||
// 시안: QR 창과 공유 시트를 함께 닫는다
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (bytes != null) await _sharePng(bytes, 'rup_${petName}_QR.png');
|
||||
if (sheet.mounted) sheet.pop();
|
||||
await _saveToAlbum(saver, toast, bytes, 'rup_${petName}_QR', 'QR 코드를 저장했어요');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,9 @@ enum RepeatUnit {
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 반복을 켰을 때 — 매일. 직접 설정을 열면 2주마다부터 (시안 repeatUnit 'week' · repeatN 2)
|
||||
const RepeatSpec kInitialRepeat = RepeatSpec(RepeatPattern.daily, n: 2);
|
||||
|
||||
/// 반복 주기 — "2주마다"
|
||||
class RepeatSpec {
|
||||
const RepeatSpec(this.mode, {this.unit = RepeatUnit.week, this.n = 1});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -158,10 +158,12 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
|
||||
// ── 일반 일정 목록 ────────────────────────────────────
|
||||
void _pickCare(CarePreset p) {
|
||||
// 이름 칸의 변경 알림이 다시 그려 준다
|
||||
_kind = EventKind.general;
|
||||
_category = p.category;
|
||||
_title.text = p.title;
|
||||
// 이름이 같으면 이름 칸이 알리지 않으므로 직접 다시 그린다 (예약 → 같은 일반 일정으로 돌아올 때)
|
||||
setState(() {
|
||||
_kind = EventKind.general;
|
||||
_category = p.category;
|
||||
_title.text = p.title;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _removeCare(CarePreset p) async {
|
||||
@@ -176,8 +178,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
if (v.isEmpty) return;
|
||||
await ref.read(carePresetsProvider.notifier).add(CarePreset(v, _category));
|
||||
if (!mounted) return;
|
||||
_kind = EventKind.general;
|
||||
_title.text = v;
|
||||
setState(() {
|
||||
_kind = EventKind.general;
|
||||
_title.text = v;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 시간 ─────────────────────────────────────────────
|
||||
@@ -251,7 +255,8 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
|
||||
// ── 저장 ─────────────────────────────────────────────
|
||||
EventDetails _details({HourMinute? startOverride, bool dropEnd = false}) {
|
||||
final start = _allDay ? null : (startOverride ?? _start);
|
||||
// 하루 여러 번(회차 시각)은 '하루 종일' 과 상관없이 그 시각으로 만든다 (시안 doseTimes)
|
||||
final start = startOverride ?? (_allDay ? null : _start);
|
||||
final place = _place.text.trim();
|
||||
return EventDetails(
|
||||
title: _title.text.trim(),
|
||||
@@ -284,7 +289,8 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
if (_saving) return;
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
if (_title.text.trim().isEmpty) {
|
||||
toast.show('일정 이름을 입력해주세요', icon: RupIconName.alert);
|
||||
// 시안 showToast 기본 아이콘(check)
|
||||
toast.show('일정 이름을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
final notifier = ref.read(scheduleProvider.notifier);
|
||||
@@ -324,13 +330,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
Future<void> _delete() async {
|
||||
final editing = widget.editing;
|
||||
if (editing == null) return;
|
||||
final ok = await showRupConfirm(
|
||||
final ok = await showRupRemoveConfirm(
|
||||
context,
|
||||
title: '일정을 제거할까요?',
|
||||
message: editing.isRepeat ? '반복으로 등록된 일정 전체가 함께 제거돼요.' : '제거한 일정은 되돌릴 수 없어요.',
|
||||
confirmLabel: '제거하기',
|
||||
destructive: true,
|
||||
layout: RupConfirmLayout.row,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
// 먼저 화면을 닫아 지워진 일정을 그리지 않게 한다
|
||||
@@ -435,11 +438,11 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
subtitle: _repeat == null ? '반복되는 일정에 활용해보세요' : '${_repeat!.label} 반복',
|
||||
on: _repeat != null,
|
||||
onChanged: (v) => setState(() {
|
||||
_repeat = v ? const RepeatSpec(RepeatPattern.daily) : null;
|
||||
_repeat = v ? kInitialRepeat : null;
|
||||
_customCycle = false;
|
||||
}),
|
||||
child: RepeatOptions(
|
||||
spec: _repeat ?? const RepeatSpec(RepeatPattern.daily),
|
||||
spec: _repeat ?? kInitialRepeat,
|
||||
custom: _customCycle,
|
||||
onSpec: (s) => setState(() => _repeat = s),
|
||||
onCustom: (v) => setState(() => _customCycle = v),
|
||||
@@ -480,6 +483,7 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
large: true,
|
||||
thin: true,
|
||||
dimmed: !hasTitle,
|
||||
dimmedOpacity: .45,
|
||||
onPressed: () => unawaited(_submit()),
|
||||
),
|
||||
if (_editing) ...[
|
||||
@@ -598,7 +602,15 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
|
||||
children: [
|
||||
const RupIcon(RupIconName.calendar, size: 15, stroke: 2, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(formatYmd(value), style: _uiStyle(14.5, FontWeight.w700, AppColors.ink)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
formatYmd(value),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
style: _uiStyle(14.5, FontWeight.w700, AppColors.ink),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -294,7 +294,8 @@ class RepeatOptions extends StatelessWidget {
|
||||
on: on,
|
||||
onTap: () {
|
||||
onCustom(false);
|
||||
onSpec(s);
|
||||
// 매일·평일·주말은 방식만 바꾸고, 직접 설정한 단위·횟수는 남겨 둔다 (시안 setRepeatMode)
|
||||
onSpec(s.mode == RepeatPattern.every ? s : spec.copyWith(mode: s.mode));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -592,7 +593,9 @@ class _CustomSwatch extends StatelessWidget {
|
||||
final Color? color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
// CSS conic-gradient 는 12시에서 시작한다 (Flutter 기본은 3시)
|
||||
static const _rainbow = SweepGradient(
|
||||
transform: GradientRotation(-pi / 2),
|
||||
colors: [
|
||||
Color(0xFFFF6B6B), Color(0xFFFFC94D), Color(0xFF3FB58C),
|
||||
Color(0xFF4DA6E0), Color(0xFFB07CF0), Color(0xFFFF6B6B),
|
||||
|
||||
@@ -59,8 +59,6 @@ class EventMeta extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
place == null || place.isEmpty ? label : '$label · $place',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
@@ -175,8 +173,6 @@ class EventTile extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
d.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall!.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: AppTypography.normalHeight,
|
||||
|
||||
@@ -44,6 +44,7 @@ class ScheduleCalendar extends StatefulWidget {
|
||||
required this.selected,
|
||||
required this.today,
|
||||
required this.onSelect,
|
||||
this.onYearViewChanged,
|
||||
});
|
||||
|
||||
final ScheduleBook book;
|
||||
@@ -51,6 +52,9 @@ class ScheduleCalendar extends StatefulWidget {
|
||||
final DateTime today;
|
||||
final ValueChanged<DateTime> onSelect;
|
||||
|
||||
/// 12개월 보기로 바뀔 때 — 일정 목록은 그동안 숨긴다
|
||||
final ValueChanged<bool>? onYearViewChanged;
|
||||
|
||||
@override
|
||||
State<ScheduleCalendar> createState() => _ScheduleCalendarState();
|
||||
}
|
||||
@@ -90,11 +94,14 @@ class _ScheduleCalendarState extends State<ScheduleCalendar> {
|
||||
}
|
||||
}
|
||||
|
||||
void _setYearView(bool value) {
|
||||
setState(() => _yearView = value);
|
||||
widget.onYearViewChanged?.call(value);
|
||||
}
|
||||
|
||||
void _pickMonth(int month) {
|
||||
setState(() {
|
||||
_month = month;
|
||||
_yearView = false;
|
||||
});
|
||||
setState(() => _month = month);
|
||||
_setYearView(false);
|
||||
widget.onSelect(DateTime(_year, month));
|
||||
}
|
||||
|
||||
@@ -108,7 +115,7 @@ class _ScheduleCalendarState extends State<ScheduleCalendar> {
|
||||
_Head(
|
||||
title: _yearView ? '$_year년' : '$_year년 $_month월',
|
||||
yearView: _yearView,
|
||||
onTitle: () => setState(() => _yearView = !_yearView),
|
||||
onTitle: () => _setYearView(!_yearView),
|
||||
onPrev: () => _step(-1),
|
||||
onNext: () => _step(1),
|
||||
),
|
||||
@@ -433,8 +440,9 @@ class _DayCell extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
SizedBox(
|
||||
width: _kDotsWidth,
|
||||
// 시안 .rup-cal-dots { max-width: 18 } — 점 묶음은 내용 폭만큼이라 칸 가운데에 놓인다
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _kDotsWidth),
|
||||
child: Wrap(
|
||||
spacing: 2.5,
|
||||
runSpacing: 2.5,
|
||||
|
||||
@@ -17,12 +17,19 @@ import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정
|
||||
class ScheduleView extends ConsumerWidget {
|
||||
/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정 (12개월 보기에서는 일정 목록을 숨긴다)
|
||||
class ScheduleView extends ConsumerStatefulWidget {
|
||||
const ScheduleView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ScheduleView> createState() => _ScheduleViewState();
|
||||
}
|
||||
|
||||
class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
||||
bool _yearView = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final book = ref.watch(scheduleBookProvider);
|
||||
final selected = ref.watch(scheduleDateProvider);
|
||||
final today = dateOnly(DateTime.now());
|
||||
@@ -32,7 +39,6 @@ class ScheduleView extends ConsumerWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const _ShareCard(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
ScheduleCalendar(
|
||||
@@ -40,55 +46,58 @@ class ScheduleView extends ConsumerWidget {
|
||||
selected: selected,
|
||||
today: today,
|
||||
onSelect: ref.read(scheduleDateProvider.notifier).select,
|
||||
onYearViewChanged: (v) => setState(() => _yearView = v),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(text: selected == today ? '오늘 일정' : '${formatMonthDay(selected)} 일정'),
|
||||
const WidgetSpan(child: SizedBox(width: 9)),
|
||||
TextSpan(
|
||||
text: '${weekdayKo(selected)}요일 · ${events.length}건',
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
if (!_yearView) ...[
|
||||
const SizedBox(height: 26),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(text: selected == today ? '오늘 일정' : '${formatMonthDay(selected)} 일정'),
|
||||
const WidgetSpan(child: SizedBox(width: 9)),
|
||||
TextSpan(
|
||||
text: '${weekdayKo(selected)}요일 · ${events.length}건',
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
]),
|
||||
maxLines: 1,
|
||||
style: textTheme.titleMedium!.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
),
|
||||
]),
|
||||
maxLines: 1,
|
||||
style: textTheme.titleMedium!.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
RupSoftPillButton(
|
||||
label: '일정 추가',
|
||||
uiFont: true,
|
||||
onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))),
|
||||
RupSoftPillButton(
|
||||
label: '일정 추가',
|
||||
uiFont: true,
|
||||
onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (events.isEmpty)
|
||||
const RupCard(child: EmptyMessage('이 날은 일정이 없어요.'))
|
||||
else
|
||||
for (final (i, e) in events.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 9),
|
||||
EventTile(
|
||||
event: e,
|
||||
done: book.isDone(e.id),
|
||||
onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))),
|
||||
onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (events.isEmpty)
|
||||
const RupCard(child: EmptyMessage('이 날은 일정이 없어요.', padding: 40))
|
||||
else
|
||||
for (final (i, e) in events.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 9),
|
||||
EventTile(
|
||||
event: e,
|
||||
done: book.isDone(e.id),
|
||||
onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))),
|
||||
onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import 'dart:ui' show ImageFilter;
|
||||
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/features/shell/domain/app_tab.dart';
|
||||
import 'package:tma_app/shared/widgets/grayscale.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
/// 시안 .rup-tabbar 의 뒤 배경 흐림 정도 (blur 18px → 표준편차 9)
|
||||
const double _kBlurSigma = 9;
|
||||
/// 시안 .rup-tabbar — backdrop-filter: blur(18px) saturate(160%). CSS blur 값이 곧 표준편차다
|
||||
const double _kBlurSigma = 18;
|
||||
const double _kSaturate = 1.6;
|
||||
const double _kActiveStroke = 2.2;
|
||||
const double _kInactiveStroke = 1.9;
|
||||
|
||||
@@ -27,7 +28,10 @@ class RupTabBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: _kBlurSigma, sigmaY: _kBlurSigma),
|
||||
filter: ImageFilter.compose(
|
||||
outer: saturateFilter(_kSaturate),
|
||||
inner: ImageFilter.blur(sigmaX: _kBlurSigma, sigmaY: _kBlurSigma),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.tabBarBg,
|
||||
@@ -64,21 +68,6 @@ class RupTabBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 제목 서체의 가운뎃점(·)은 폭이 넓고 왼쪽에 붙어 있다.
|
||||
/// 브라우저는 문장부호 간격을 자동으로 줄이지만 Flutter 는 그대로 그리므로
|
||||
/// 가운뎃점만 본문 서체로 바꿔 "일정·기록" 처럼 붙여 보이게 한다.
|
||||
TextSpan _narrowDots(String label) {
|
||||
final parts = label.split('·');
|
||||
return TextSpan(
|
||||
children: [
|
||||
for (final (i, part) in parts.indexed) ...[
|
||||
if (i > 0) const TextSpan(text: '·', style: TextStyle(fontFamily: AppFonts.ui)),
|
||||
TextSpan(text: part),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _TabItem extends StatelessWidget {
|
||||
const _TabItem({required this.tab, required this.active, required this.onTap});
|
||||
|
||||
@@ -124,7 +113,7 @@ class _TabItem extends StatelessWidget {
|
||||
color: color,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
child: Text.rich(_narrowDots(tab.label), maxLines: 1, softWrap: false),
|
||||
child: Text(tab.label, maxLines: 1, softWrap: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
|
||||
/// 시트·확인창 뒤 화면 흐림 — 시안 .rup-overlay { backdrop-filter: blur(2px) }
|
||||
const double _kBarrierBlur = 2;
|
||||
|
||||
/// 가림막 뒤 화면을 등장 정도에 맞춰 흐리게 한다
|
||||
class _BlurredBarrier extends StatelessWidget {
|
||||
const _BlurredBarrier({required this.animation, required this.child});
|
||||
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final sigma = _kBarrierBlur * animation.value;
|
||||
return BackdropFilter(filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), child: child);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 아래에서 올라오는 시트 — 가림막 뒤를 흐린다
|
||||
class BlurredSheetRoute<T> extends ModalBottomSheetRoute<T> {
|
||||
BlurredSheetRoute({
|
||||
required super.builder,
|
||||
required super.isScrollControlled,
|
||||
super.capturedThemes,
|
||||
super.barrierLabel,
|
||||
super.backgroundColor,
|
||||
super.shape,
|
||||
super.clipBehavior,
|
||||
super.constraints,
|
||||
super.modalBarrierColor,
|
||||
super.sheetAnimationStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget buildModalBarrier() => _BlurredBarrier(animation: animation!, child: super.buildModalBarrier());
|
||||
}
|
||||
|
||||
/// 가운데 확인창 — 가림막 뒤를 흐린다
|
||||
class BlurredDialogRoute<T> extends DialogRoute<T> {
|
||||
BlurredDialogRoute({required super.context, required super.builder, super.themes})
|
||||
: super(barrierColor: AppColors.scrim);
|
||||
|
||||
@override
|
||||
Widget buildModalBarrier() => _BlurredBarrier(animation: animation!, child: super.buildModalBarrier());
|
||||
}
|
||||
|
||||
/// [showDialog] 대신 — 앱 전체(루트) 위에 흐린 가림막과 함께 띄운다
|
||||
Future<T?> showBlurredDialog<T>(BuildContext context, {required WidgetBuilder builder}) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
return navigator.push(
|
||||
BlurredDialogRoute<T>(
|
||||
context: context,
|
||||
builder: builder,
|
||||
themes: InheritedTheme.capture(from: context, to: navigator.context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/// CSS `saturate(amount)` 와 같은 색 행렬 — 1 이면 그대로, 1 보다 크면 진하게
|
||||
ColorFilter saturateFilter(double amount) {
|
||||
final s = amount;
|
||||
return ColorFilter.matrix([
|
||||
_kR + (1 - _kR) * s, _kG - _kG * s, _kB - _kB * s, 0, 0, //
|
||||
_kR - _kR * s, _kG + (1 - _kG) * s, _kB - _kB * s, 0, 0, //
|
||||
_kR - _kR * s, _kG - _kG * s, _kB + (1 - _kB) * s, 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);
|
||||
}
|
||||
@@ -6,59 +6,82 @@ const double _kWidth = 44;
|
||||
const double _kHeight = 22;
|
||||
const double _kInset = 3.5;
|
||||
const double _kShift = 22;
|
||||
const double _kRadius = 20;
|
||||
const Duration _kSlide = Duration(milliseconds: 850);
|
||||
const Cubic _kSlideCurve = Cubic(.4, .8, .35, 1);
|
||||
const Color _kOffTrack = Color(0xFFECE7DF);
|
||||
|
||||
/// 설정 켜고 끄기 (시안 HeartToggle)
|
||||
class HeartToggle extends StatelessWidget {
|
||||
/// 트랙 안쪽 그림자 — 시안 box-shadow: 0 0 6px 0 rgba(0,0,0,.05) inset
|
||||
const Color _kInnerShadow = Color(0x0D000000);
|
||||
const double _kInnerShadowBlur = 6;
|
||||
|
||||
/// 설정 켜고 끄기 (시안 HeartToggle) — 누르는 동안 흰 판이 가운데로 모인다
|
||||
class HeartToggle extends StatefulWidget {
|
||||
const HeartToggle({super.key, required this.value, required this.onChanged, required this.label});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
State<HeartToggle> createState() => _HeartToggleState();
|
||||
}
|
||||
|
||||
class _HeartToggleState extends State<HeartToggle> {
|
||||
bool _pressed = false;
|
||||
|
||||
void _press(bool v) {
|
||||
if (_pressed != v) setState(() => _pressed = v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const innerWidth = _kWidth - _kInset * 2;
|
||||
final value = widget.value;
|
||||
return Semantics(
|
||||
container: true,
|
||||
toggled: value,
|
||||
label: label,
|
||||
label: widget.label,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
width: _kWidth,
|
||||
height: _kHeight,
|
||||
padding: const EdgeInsets.all(_kInset),
|
||||
decoration: BoxDecoration(
|
||||
color: value ? AppColors.primary : _kOffTrack,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _kSlide,
|
||||
curve: _kSlideCurve,
|
||||
left: value ? _kShift : -_kShift,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: innerWidth,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [BoxShadow(color: Color(0x24000000), blurRadius: 8, spreadRadius: 2)],
|
||||
onTap: () => widget.onChanged(!value),
|
||||
onTapDown: (_) => _press(true),
|
||||
onTapUp: (_) => _press(false),
|
||||
onTapCancel: () => _press(false),
|
||||
child: CustomPaint(
|
||||
foregroundPainter: const _InnerShadowPainter(),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
width: _kWidth,
|
||||
height: _kHeight,
|
||||
padding: const EdgeInsets.all(_kInset),
|
||||
decoration: BoxDecoration(
|
||||
color: value ? AppColors.primary : _kOffTrack,
|
||||
borderRadius: BorderRadius.circular(_kRadius),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_kRadius),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _kSlide,
|
||||
curve: _kSlideCurve,
|
||||
left: _pressed ? 0 : (value ? _kShift : -_kShift),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: innerWidth,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(_kRadius),
|
||||
boxShadow: const [BoxShadow(color: Color(0x24000000), blurRadius: 8, spreadRadius: 2)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -66,3 +89,31 @@ class HeartToggle extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 둥근 트랙 안쪽 가장자리를 살짝 어둡게 — CSS inset 그림자
|
||||
class _InnerShadowPainter extends CustomPainter {
|
||||
const _InnerShadowPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rrect = RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kRadius));
|
||||
// 트랙 바깥을 두른 띠를 흐리게 그리고 트랙 안으로 잘라 내면 안쪽 그림자가 된다
|
||||
final ring = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect((Offset.zero & size).inflate(_kInnerShadowBlur * 2))
|
||||
..addRRect(rrect);
|
||||
canvas
|
||||
..save()
|
||||
..clipRRect(rrect)
|
||||
..drawPath(
|
||||
ring,
|
||||
Paint()
|
||||
..color = _kInnerShadow
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, Shadow.convertRadiusToSigma(_kInnerShadowBlur)),
|
||||
)
|
||||
..restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_InnerShadowPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class Pressable extends StatefulWidget {
|
||||
required this.child,
|
||||
required this.onTap,
|
||||
this.pressedScale = 0.95,
|
||||
this.pressedBrightness = 1,
|
||||
this.semanticLabel,
|
||||
this.behavior = HitTestBehavior.opaque,
|
||||
});
|
||||
@@ -17,6 +18,9 @@ class Pressable extends StatefulWidget {
|
||||
final VoidCallback? onTap;
|
||||
final double pressedScale;
|
||||
|
||||
/// 누르는 동안 밝기 — 시안 filter: brightness(.94) 등 (1 이면 그대로)
|
||||
final double pressedBrightness;
|
||||
|
||||
/// 스크린리더용 이름. 없으면 자식의 텍스트를 그대로 읽는다.
|
||||
final String? semanticLabel;
|
||||
final HitTestBehavior behavior;
|
||||
@@ -55,10 +59,20 @@ class _PressableState extends State<Pressable> {
|
||||
scale: _pressed ? widget.pressedScale : 1,
|
||||
duration: AppDurations.press,
|
||||
curve: Curves.easeOut,
|
||||
child: widget.child,
|
||||
child: _pressed && widget.pressedBrightness != 1
|
||||
? ColorFiltered(colorFilter: _brightness(widget.pressedBrightness), child: widget.child)
|
||||
: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS brightness(k) — 색 채널을 k 배로
|
||||
ColorFilter _brightness(double k) => ColorFilter.matrix([
|
||||
k, 0, 0, 0, 0, //
|
||||
0, k, 0, 0, 0, //
|
||||
0, 0, k, 0, 0, //
|
||||
0, 0, 0, 1, 0,
|
||||
]);
|
||||
|
||||
@@ -24,6 +24,9 @@ enum RupButtonVariant {
|
||||
|
||||
/// 주황 화면 위의 흰 버튼 (인트로·온보딩 CTA — .rup-btn 이 아니라 본문 서체)
|
||||
light,
|
||||
|
||||
/// 추억 보관함 보라 (보관하기 확인)
|
||||
memory,
|
||||
}
|
||||
|
||||
const double _kFontSize = 15;
|
||||
@@ -100,6 +103,7 @@ class RupButton extends StatelessWidget {
|
||||
this.thin = false,
|
||||
this.expand = true,
|
||||
this.dimmed = false,
|
||||
this.dimmedOpacity = .5,
|
||||
this.height,
|
||||
this.leading,
|
||||
});
|
||||
@@ -120,6 +124,9 @@ class RupButton extends StatelessWidget {
|
||||
/// 누를 수는 있지만 아직 조건이 안 된 상태를 흐리게 보여 준다 (등록 버튼 등)
|
||||
final bool dimmed;
|
||||
|
||||
/// 흐리게 보일 때 불투명도 — 시안이 화면마다 다르다 (등록 .5 / 일정 저장 .45)
|
||||
final double dimmedOpacity;
|
||||
|
||||
/// 높이를 시안 값으로 고정해야 하는 자리(인트로 CTA 46 등)에서만 지정
|
||||
final double? height;
|
||||
final Widget? leading;
|
||||
@@ -141,6 +148,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
|
||||
@@ -164,9 +172,12 @@ class RupButton extends StatelessWidget {
|
||||
);
|
||||
|
||||
return Opacity(
|
||||
opacity: onPressed == null || dimmed ? 0.5 : 1,
|
||||
opacity: onPressed == null || dimmed ? dimmedOpacity : 1,
|
||||
child: Pressable(
|
||||
onTap: onPressed,
|
||||
// 주황 화면 위 흰 버튼(인트로)은 살짝만 줄고 어두워진다
|
||||
pressedScale: variant == RupButtonVariant.light ? .99 : .95,
|
||||
pressedBrightness: variant == RupButtonVariant.light ? .94 : 1,
|
||||
child: Container(
|
||||
height: height,
|
||||
width: expand ? double.infinity : null,
|
||||
|
||||
@@ -2,7 +2,10 @@ 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_shadows.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/shared/widgets/blurred_modal.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_button.dart';
|
||||
|
||||
const double _kDialogMaxWidth = 300;
|
||||
@@ -26,13 +29,14 @@ Future<bool> showRupConfirm(
|
||||
bool destructive = false,
|
||||
RupConfirmLayout layout = RupConfirmLayout.stacked,
|
||||
RupIconName? icon,
|
||||
RupGlyph? glyph,
|
||||
Color iconColor = AppColors.ink2,
|
||||
Color? iconBackground,
|
||||
RupButtonVariant? confirmVariant,
|
||||
bool tightTop = false,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierColor: AppColors.scrim,
|
||||
final result = await showBlurredDialog<bool>(
|
||||
context,
|
||||
builder: (context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final row = layout == RupConfirmLayout.row;
|
||||
@@ -54,8 +58,9 @@ Future<bool> showRupConfirm(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (row) const SizedBox(height: AppSpacing.xs),
|
||||
if (icon != null)
|
||||
// 시안 row 확인창은 아이콘·제목 위에 4 를 둔다 (댓글 삭제처럼 없는 곳은 tightTop)
|
||||
if (row && !tightTop) const SizedBox(height: AppSpacing.xs),
|
||||
if (icon != null || glyph != null)
|
||||
Center(
|
||||
child: Container(
|
||||
width: 54,
|
||||
@@ -66,7 +71,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 +106,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 +116,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),
|
||||
@@ -131,3 +138,114 @@ Future<bool> showRupConfirm(
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// 제거 확인창 치수 — 시안 일정·다이어리 제거 (maxWidth 306, padding 26/20/20, 모서리 22)
|
||||
const double _kRemoveMaxWidth = 306;
|
||||
const double _kRemoveRadius = 22;
|
||||
const double _kRemoveButtonRadius = 14;
|
||||
|
||||
/// 제거 확인창 — 일정·다이어리를 지울 때의 전용 모양 (제목 서체 제목, 모서리 버튼 두 개). 확인하면 true
|
||||
Future<bool> showRupRemoveConfirm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmLabel = '제거하기',
|
||||
String cancelLabel = '취소',
|
||||
}) async {
|
||||
final result = await showBlurredDialog<bool>(
|
||||
context,
|
||||
builder: (context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final button = textTheme.bodyMedium!.copyWith(fontSize: 14, height: AppTypography.normalHeight);
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _kRemoveMaxWidth),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.xl, 26, AppSpacing.xl, AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(_kRemoveRadius),
|
||||
border: Border.all(color: AppColors.line2),
|
||||
boxShadow: AppShadows.sm,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleMedium!.copyWith(
|
||||
fontSize: 17.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -.175,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.6, color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _RemoveButton(
|
||||
label: cancelLabel,
|
||||
style: button.copyWith(fontWeight: FontWeight.w700, color: AppColors.ink2),
|
||||
border: AppColors.line,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: _RemoveButton(
|
||||
label: confirmLabel,
|
||||
style: button.copyWith(fontWeight: FontWeight.w800, color: Colors.white),
|
||||
fill: AppColors.danger,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _RemoveButton extends StatelessWidget {
|
||||
const _RemoveButton({required this.label, required this.style, required this.onTap, this.fill, this.border});
|
||||
|
||||
final String label;
|
||||
final TextStyle style;
|
||||
final VoidCallback onTap;
|
||||
final Color? fill;
|
||||
final Color? border;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Pressable(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: BorderRadius.circular(_kRemoveButtonRadius),
|
||||
border: border == null ? null : Border.all(color: border!, width: 1.5),
|
||||
),
|
||||
child: Text(label, style: style),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/shared/widgets/dashed_border_box.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
@@ -43,10 +44,14 @@ class RupField extends StatelessWidget {
|
||||
required this.child,
|
||||
this.labelSuffix,
|
||||
this.errorText,
|
||||
this.labelGap = 7,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// 라벨 아래 간격 — 시안이 칸마다 덮어쓰는 곳이 있다 (기록 수정 12)
|
||||
final double labelGap;
|
||||
|
||||
/// 라벨 옆 보조 설명 — 예: (주의가 필요한 항목)
|
||||
final String? labelSuffix;
|
||||
final Widget child;
|
||||
@@ -66,7 +71,7 @@ class RupField extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 7),
|
||||
padding: EdgeInsets.only(bottom: labelGap),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: label,
|
||||
@@ -263,19 +268,24 @@ class RupPickerField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 두 개 중 하나를 고르는 버튼 (.rup-seg.rup-seg--compact) — 성별·중성화
|
||||
/// 두 개 중 하나를 고르는 버튼 (.rup-seg) — 기본은 컴팩트 변형(성별·중성화).
|
||||
/// [icons] 를 주면 기본 변형 — 제목 서체 14 / 800, 여백 12, 앞에 아이콘 (다이어리 종류)
|
||||
class RupSegmented<T> extends StatelessWidget {
|
||||
const RupSegmented({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.icons,
|
||||
});
|
||||
|
||||
final List<(T, String)> options;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
|
||||
/// 선택지마다 앞에 붙는 아이콘 (15, 굵기 2, 오른쪽 5)
|
||||
final List<RupIconName>? icons;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
@@ -283,6 +293,7 @@ class RupSegmented<T> extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
);
|
||||
final icons = this.icons;
|
||||
return Row(
|
||||
children: [
|
||||
for (final (index, (option, label)) in options.indexed) ...[
|
||||
@@ -297,7 +308,9 @@ class RupSegmented<T> extends StatelessWidget {
|
||||
onTap: () => onChanged(option),
|
||||
child: AnimatedContainer(
|
||||
duration: AppDurations.fast,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 11),
|
||||
padding: icons == null
|
||||
? const EdgeInsets.symmetric(horizontal: 8, vertical: 11)
|
||||
: const EdgeInsets.all(AppSpacing.md),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: option == value ? AppColors.primary50 : AppColors.surface,
|
||||
@@ -307,12 +320,35 @@ class RupSegmented<T> extends StatelessWidget {
|
||||
width: _kInputBorder,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: base.copyWith(
|
||||
color: option == value ? AppColors.primary600 : AppColors.ink2,
|
||||
),
|
||||
),
|
||||
child: icons == null
|
||||
? Text(
|
||||
label,
|
||||
style: base.copyWith(
|
||||
color: option == value ? AppColors.primary600 : AppColors.ink2,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RupIcon(
|
||||
icons[index],
|
||||
size: 15,
|
||||
stroke: 2,
|
||||
color: option == value ? AppColors.primary600 : AppColors.ink2,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: AppTypography.button(
|
||||
14,
|
||||
color: option == value ? AppColors.primary600 : AppColors.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
||||
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/shared/widgets/blurred_modal.dart';
|
||||
|
||||
/// 시트 등장 곡선·시간 — 시안 rup-rise .28s cubic-bezier(.2,.8,.25,1)
|
||||
const Duration _kSheetDuration = Duration(milliseconds: 280);
|
||||
@@ -19,12 +20,14 @@ Future<T?> showRupSheet<T>(
|
||||
bool flushBottom = false,
|
||||
required WidgetBuilder builder,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final localizations = MaterialLocalizations.of(context);
|
||||
return navigator.push(BlurredSheetRoute<T>(
|
||||
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
|
||||
barrierLabel: localizations.scrimOnTapHint(localizations.bottomSheetLabel),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.bg,
|
||||
barrierColor: AppColors.scrim,
|
||||
modalBarrierColor: AppColors.scrim,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(_kSheetRadius)),
|
||||
@@ -41,7 +44,7 @@ Future<T?> showRupSheet<T>(
|
||||
flushBottom: flushBottom,
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
class _RupSheetBody extends StatelessWidget {
|
||||
|
||||
@@ -13,11 +13,18 @@ const Duration _kToastAnim = Duration(milliseconds: 300);
|
||||
/// 화면 아래에서 토스트까지 — 시안 .rup-toast { bottom: 118px } (탭바 위)
|
||||
const double _kToastBottom = 118;
|
||||
|
||||
/// 토스트 색 — 기본(크림) / 추억 보관함(보라, 시안 .rup-toast--memory)
|
||||
enum ToastTone { normal, memory }
|
||||
|
||||
/// 추억 보관함 토스트 색 (시안: 바탕 memory-50, 테두리 memory+흰 68%, 글자 #6E5A86)
|
||||
const Color _kMemoryToastText = Color(0xFF6E5A86);
|
||||
|
||||
class ToastMessage {
|
||||
const ToastMessage(this.text, this.icon, this.serial);
|
||||
const ToastMessage(this.text, this.icon, this.serial, {this.tone = ToastTone.normal});
|
||||
|
||||
final String text;
|
||||
final RupIconName icon;
|
||||
final ToastTone tone;
|
||||
|
||||
/// 같은 문구를 연달아 띄워도 다시 나타나게 하는 구분값
|
||||
final int serial;
|
||||
@@ -34,9 +41,9 @@ class ToastNotifier extends Notifier<ToastMessage?> {
|
||||
return null;
|
||||
}
|
||||
|
||||
void show(String text, {RupIconName icon = RupIconName.check}) {
|
||||
void show(String text, {RupIconName icon = RupIconName.check, ToastTone tone = ToastTone.normal}) {
|
||||
_timer?.cancel();
|
||||
state = ToastMessage(text, icon, ++_serial);
|
||||
state = ToastMessage(text, icon, ++_serial, tone: tone);
|
||||
_timer = Timer(_kToastVisible, () => state = null);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +97,8 @@ class _ToastBubble extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final memory = message.tone == ToastTone.memory;
|
||||
final ink = memory ? _kMemoryToastText : AppColors.ink2;
|
||||
return Semantics(
|
||||
liveRegion: true,
|
||||
child: Material(
|
||||
@@ -97,22 +106,27 @@ class _ToastBubble extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface2,
|
||||
color: memory ? AppColors.memory50 : AppColors.surface2,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.line),
|
||||
border: Border.all(
|
||||
color: memory ? Color.lerp(AppColors.memory, Colors.white, .68)! : AppColors.line,
|
||||
),
|
||||
boxShadow: AppShadows.md,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RupIcon(message.icon, size: 16, stroke: 2.2, color: AppColors.ink2),
|
||||
if (memory)
|
||||
const RupGlyphIcon(RupGlyph.memory, size: 16, color: AppColors.memory)
|
||||
else
|
||||
RupIcon(message.icon, size: 16, stroke: 2.2, color: ink),
|
||||
const SizedBox(width: 8),
|
||||
// 시안은 한 줄(nowrap)이지만, 화면보다 긴 안내는 넘치지 않게 줄을 바꾼다
|
||||
Flexible(
|
||||
child: Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: AppColors.ink2,
|
||||
color: ink,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
|
||||
@@ -6,6 +6,9 @@ import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
const double _kTitleLinkPad = 2;
|
||||
|
||||
/// 빈 상태 위아래 여백 (시안 .rup-empty)
|
||||
const double _kEmptyPadV = 40;
|
||||
|
||||
/// 섹션 제목 줄 — 왼쪽 제목, 오른쪽 "더보기 ›" 같은 보조 링크나 설명 (시안 .rup-section-title)
|
||||
class SectionTitle extends StatelessWidget {
|
||||
const SectionTitle({
|
||||
@@ -14,6 +17,8 @@ class SectionTitle extends StatelessWidget {
|
||||
this.trailing,
|
||||
this.onTrailingTap,
|
||||
this.sub = false,
|
||||
this.subDisplayFont = false,
|
||||
this.smallTrailing = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -25,10 +30,23 @@ class SectionTitle extends StatelessWidget {
|
||||
/// 소제목(.is-sub) — 본문 서체 14, 흐린 색
|
||||
final bool sub;
|
||||
|
||||
/// 소제목을 제목 서체로 — 시안에서 인라인으로 본문 서체를 주지 않은 곳(다이어리 보기 이름)
|
||||
final bool subDisplayFont;
|
||||
|
||||
/// 오른쪽 링크를 작게 — 12 / 600, › 와 간격 1 (홈 "아이 상세보기")
|
||||
final bool smallTrailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final titleStyle = sub
|
||||
final titleStyle = sub && subDisplayFont
|
||||
? textTheme.titleMedium!.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
)
|
||||
: sub
|
||||
? textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -38,7 +56,8 @@ class SectionTitle extends StatelessWidget {
|
||||
// 줄 높이를 지정하지 않으면 본문 기본값(1.6)을 물려받아 시안 h2(글자 높이 그대로)보다 줄이 커진다
|
||||
: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight);
|
||||
final trailingStyle = textTheme.bodySmall!.copyWith(
|
||||
fontSize: 13,
|
||||
fontSize: smallTrailing ? 12 : 13,
|
||||
fontWeight: smallTrailing ? FontWeight.w600 : null,
|
||||
color: AppColors.muted,
|
||||
height: 1.2,
|
||||
);
|
||||
@@ -63,7 +82,7 @@ class SectionTitle extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(trailing!, style: trailingStyle),
|
||||
const SizedBox(width: 2),
|
||||
SizedBox(width: smallTrailing ? 1 : 2),
|
||||
const RupIcon(RupIconName.chevR, size: 13, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
@@ -76,17 +95,22 @@ class SectionTitle extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 빈 상태 문구 (시안 .rup-empty)
|
||||
/// 빈 상태 문구 (시안 .rup-empty — 여백 40/20)
|
||||
class EmptyMessage extends StatelessWidget {
|
||||
const EmptyMessage(this.text, {super.key, this.padding = 28});
|
||||
const EmptyMessage(this.text, {super.key, this.padding});
|
||||
|
||||
final String text;
|
||||
final double padding;
|
||||
|
||||
/// 시안이 인라인으로 준 사방 여백 — 없으면 .rup-empty 기본(위아래 40, 좌우 20)
|
||||
final double? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final all = padding;
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(padding),
|
||||
padding: all == null
|
||||
? const EdgeInsets.symmetric(vertical: _kEmptyPadV, horizontal: AppSpacing.xl)
|
||||
: EdgeInsets.all(all),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -102,13 +126,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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +131,18 @@ class _TimePickerBodyState extends State<_TimePickerBody> {
|
||||
rows: _kRows,
|
||||
showBand: false,
|
||||
loop: true,
|
||||
parseInput: (s) => _clampInput(s, 1, 12),
|
||||
onChanged: (h) => _change(() => _h12 = h),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
':',
|
||||
style: TextStyle(fontFamily: AppFonts.display, fontSize: 24, color: AppColors.ink2),
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 24,
|
||||
color: AppColors.ink2,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: WheelColumn<int>(
|
||||
@@ -147,6 +153,7 @@ class _TimePickerBodyState extends State<_TimePickerBody> {
|
||||
rows: _kRows,
|
||||
showBand: false,
|
||||
loop: true,
|
||||
parseInput: (s) => _clampInput(s, 0, 59),
|
||||
onChanged: (m) => _change(() => _m = m),
|
||||
),
|
||||
),
|
||||
@@ -160,3 +167,9 @@ class _TimePickerBodyState extends State<_TimePickerBody> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휠 직접 입력 — 숫자가 아니면 null, 범위 밖이면 가장 가까운 값
|
||||
int? _clampInput(String input, int min, int max) {
|
||||
final n = int.tryParse(input);
|
||||
return n?.clamp(min, max);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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';
|
||||
@@ -10,6 +11,10 @@ const double _kSelectedFont = 21;
|
||||
const double _kIdleFont = 17;
|
||||
const double _kIdleOpacity = .45;
|
||||
|
||||
/// 직접 입력 칸 (시안 WheelColumn editable — 가운데 줄을 누르면 키패드)
|
||||
const int _kInputMaxDigits = 2;
|
||||
const double _kInputRadius = 12;
|
||||
|
||||
/// 위아래로 돌려 값을 고르는 열 (시안 WheelColumn).
|
||||
/// 가운데 줄이 선택값이며, 다른 줄을 누르면 그 줄로 굴러가 선택된다.
|
||||
class WheelColumn<T> extends StatefulWidget {
|
||||
@@ -23,6 +28,7 @@ class WheelColumn<T> extends StatefulWidget {
|
||||
this.rows = _kDefaultRows,
|
||||
this.showBand = true,
|
||||
this.loop = false,
|
||||
this.parseInput,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
@@ -40,6 +46,10 @@ class WheelColumn<T> extends StatefulWidget {
|
||||
/// 끝이 없이 돌아가는 열 — 시·분 (시안 loop)
|
||||
final bool loop;
|
||||
|
||||
/// 있으면 가운데(선택된) 줄을 눌러 숫자를 직접 입력한다 — 시·분 (시안 editable).
|
||||
/// 입력한 글자를 값으로 바꾸고, 범위 밖이면 가장 가까운 값으로 맞춰 돌려준다
|
||||
final T? Function(String input)? parseInput;
|
||||
|
||||
@override
|
||||
State<WheelColumn<T>> createState() => _WheelColumnState<T>();
|
||||
}
|
||||
@@ -55,6 +65,10 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
/// 굴린 위치 (끝없는 열이면 목록 길이를 넘는다 — 항목은 나머지로 구한다)
|
||||
late int _selected = _indexOf(widget.value);
|
||||
|
||||
final _input = TextEditingController();
|
||||
final _inputFocus = FocusNode();
|
||||
bool _editing = false;
|
||||
|
||||
int get _count => widget.items.length;
|
||||
|
||||
T _itemAt(int index) => widget.items[index % _count];
|
||||
@@ -65,12 +79,44 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
return widget.loop ? _count * _kLoopLaps + base : base;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 칸 밖을 누르거나 키보드를 닫으면 입력한 값으로 맞춘다
|
||||
_inputFocus.addListener(() {
|
||||
if (!_inputFocus.hasFocus && _editing) _commitInput();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_input.dispose();
|
||||
_inputFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openInput() {
|
||||
final label = widget.labelOf(_itemAt(_selected));
|
||||
_input
|
||||
..text = label
|
||||
..selection = TextSelection(baseOffset: 0, extentOffset: label.length);
|
||||
setState(() => _editing = true);
|
||||
_inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _commitInput() {
|
||||
final value = widget.parseInput?.call(_input.text);
|
||||
setState(() => _editing = false);
|
||||
if (value == null) return;
|
||||
final i = widget.items.indexOf(value);
|
||||
if (i < 0) return;
|
||||
// 끝없는 열이면 지금 돌고 있는 바퀴 안에서 맞춘다
|
||||
final target = widget.loop ? _selected - _selected % _count + i : i;
|
||||
_controller.jumpToItem(target);
|
||||
_select(target);
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
setState(() => _selected = index);
|
||||
final value = _itemAt(index);
|
||||
@@ -108,16 +154,20 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
final on = index == _selected;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _controller.animateToItem(
|
||||
index,
|
||||
duration: AppDurations.fade,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
onTap: () => on && widget.parseInput != null
|
||||
? _openInput()
|
||||
: _controller.animateToItem(
|
||||
index,
|
||||
duration: AppDurations.fade,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
// 시안: 인라인 제목 서체 → 굵기 800 (styles.css [style*=font-display])
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: on ? _kSelectedFont : _kIdleFont,
|
||||
height: 1,
|
||||
color: on
|
||||
@@ -133,6 +183,14 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
),
|
||||
// 위아래 흐림 (bg → 투명 28% · 72% → bg)
|
||||
const IgnorePointer(child: _EdgeFade()),
|
||||
if (_editing)
|
||||
Positioned(
|
||||
left: 2,
|
||||
right: 2,
|
||||
top: bandTop,
|
||||
height: kWheelItemExtent,
|
||||
child: _WheelInput(controller: _input, focusNode: _inputFocus, onDone: _commitInput),
|
||||
),
|
||||
if (widget.suffix != null)
|
||||
Positioned(
|
||||
top: bandTop,
|
||||
@@ -169,6 +227,49 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 가운데 줄 위에 겹치는 숫자 입력 칸 (시안: 테두리 1.5 primary-600, 모서리 12, 제목 서체 21)
|
||||
class _WheelInput extends StatelessWidget {
|
||||
const _WheelInput({required this.controller, required this.focusNode, required this.onDone});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bg,
|
||||
borderRadius: BorderRadius.circular(_kInputRadius),
|
||||
border: Border.all(color: AppColors.primary600, width: 1.5),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(_kInputMaxDigits),
|
||||
],
|
||||
cursorColor: AppColors.primary600,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: _kSelectedFont,
|
||||
height: 1,
|
||||
color: AppColors.primary600,
|
||||
),
|
||||
decoration: const InputDecoration.collapsed(hintText: null),
|
||||
onSubmitted: (_) => onDone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionBand extends StatelessWidget {
|
||||
const _SelectionBand();
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import flutter_secure_storage_darwin
|
||||
import gal
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
+12
-4
@@ -242,10 +242,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
version: "3.3.2"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -320,6 +320,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
gal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: gal
|
||||
sha256: f71e79840fe023a21f2f949771375444b6efcd34b9e625d5f4f5504971380a77
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -684,10 +692,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.3.2"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -45,6 +45,7 @@ dependencies:
|
||||
path_provider: ^2.1.6
|
||||
share_plus: ^13.3.0
|
||||
qr_flutter: ^4.1.0
|
||||
gal: ^2.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/utils/korean.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_book.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet_archive.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
final PetArchive _archive = PetArchive(
|
||||
from: DateTime(2021, 9, 14),
|
||||
to: DateTime(2026, 9, 28),
|
||||
archivedAt: DateTime(2026, 9, 28, 21, 5),
|
||||
memo: '고마웠어',
|
||||
);
|
||||
|
||||
Diary _diary(String id, List<String> targets) =>
|
||||
Diary(id: id, name: id, targetIds: targets, ribbon: kRibbonColors.first, tint: kDiaryTints.first);
|
||||
|
||||
DiaryPost _post(String id, String diaryId, DateTime date) =>
|
||||
DiaryPost(id: id, diaryId: diaryId, date: date, createdAt: date, text: id, mood: Mood.happy);
|
||||
|
||||
void main() {
|
||||
group('togetherSpan', () {
|
||||
test('해와 달로 읽는다', () {
|
||||
expect(togetherSpan(DateTime(2018, 6, 1), DateTime(2026, 9, 1)), '8년 3개월');
|
||||
expect(togetherSpan(DateTime(2024, 3, 10), DateTime(2026, 3, 10)), '2년');
|
||||
expect(togetherSpan(DateTime(2026, 4, 1), DateTime(2026, 9, 15)), '5개월');
|
||||
});
|
||||
|
||||
test('마지막 날이 첫날의 날짜보다 이르면 그 달은 세지 않는다', () {
|
||||
expect(togetherSpan(DateTime(2021, 9, 14), DateTime(2026, 9, 13)), '4년 11개월');
|
||||
});
|
||||
|
||||
test('한 달이 안 되면 "한 달"', () {
|
||||
expect(togetherSpan(DateTime(2026, 9, 1), DateTime(2026, 9, 20)), '한 달');
|
||||
expect(togetherSpan(DateTime(2026, 9, 20), DateTime(2026, 9, 1)), '한 달');
|
||||
});
|
||||
});
|
||||
|
||||
test('공동격 조사는 받침에 따라 와/과', () {
|
||||
expect(withAndParticle('콩이'), '콩이와');
|
||||
expect(withAndParticle('사랑'), '사랑과');
|
||||
expect(withAndParticle('Coco'), 'Coco와');
|
||||
});
|
||||
|
||||
group('보관 기록 저장', () {
|
||||
test('보관 기록은 JSON 으로 오가도 같다', () {
|
||||
final back = PetArchive.fromJson(_archive.toJson());
|
||||
expect(back.from, _archive.from);
|
||||
expect(back.to, _archive.to);
|
||||
expect(back.archivedAt, _archive.archivedAt);
|
||||
expect(back.memo, '고마웠어');
|
||||
expect(back.spanLabel, '5년');
|
||||
});
|
||||
|
||||
test('보관한 아이는 보관 기록까지 저장되고, 함께 지내는 아이는 보관 항목이 없다', () {
|
||||
final pet = samplePet();
|
||||
expect(pet.toJson().containsKey('archive'), isFalse);
|
||||
expect(Pet.fromJson(pet.toJson()).isArchived, isFalse);
|
||||
|
||||
final archived = Pet.fromJson(pet.withArchive(_archive).toJson());
|
||||
expect(archived.isArchived, isTrue);
|
||||
expect(archived.archive!.memo, '고마웠어');
|
||||
expect(archived.name, pet.name);
|
||||
expect(archived.tint, pet.tint);
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalPetRepository — 보관·복구', () {
|
||||
Future<LocalPetRepository> repo() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final r = LocalPetRepository(await SharedPreferences.getInstance());
|
||||
await r.create(samplePet());
|
||||
return r;
|
||||
}
|
||||
|
||||
test('보관하면 보관 기록이 붙고, 다시 불러오면 떨어진다', () async {
|
||||
final r = await repo();
|
||||
final id = (await r.fetchPets()).single.id;
|
||||
|
||||
await r.archive(id, _archive);
|
||||
expect((await r.fetchPets()).single.isArchived, isTrue);
|
||||
|
||||
await r.restore(id);
|
||||
expect((await r.fetchPets()).single.isArchived, isFalse);
|
||||
});
|
||||
|
||||
test('정보를 수정해도 보관 기록은 그대로다', () async {
|
||||
final r = await repo();
|
||||
final id = (await r.fetchPets()).single.id;
|
||||
await r.archive(id, _archive);
|
||||
|
||||
final updated = await r.update(id, samplePet(name: '콩순이'));
|
||||
expect(updated.name, '콩순이');
|
||||
expect(updated.isArchived, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('보관된 아이의 다이어리', () {
|
||||
final book = DiaryBook(
|
||||
diaries: [
|
||||
_diary('only', ['pet-1']),
|
||||
_diary('mixed', ['pet-1', 'pet-2']),
|
||||
_diary('other', ['pet-2']),
|
||||
],
|
||||
posts: [
|
||||
_post('a', 'only', DateTime(2026, 9, 20)),
|
||||
_post('b', 'mixed', DateTime(2026, 9, 21)),
|
||||
_post('c', 'other', DateTime(2026, 9, 22)),
|
||||
],
|
||||
);
|
||||
|
||||
test('보관된 아이만 담은 다이어리는 책장에서 빠진다', () {
|
||||
expect(book.shelf({'pet-1'}).map((d) => d.id), ['mixed', 'other']);
|
||||
expect(book.shelf().map((d) => d.id), ['only', 'mixed', 'other']);
|
||||
});
|
||||
|
||||
test('책장이 가득 찼는지는 보관함으로 간 다이어리를 빼고 센다', () {
|
||||
final full = DiaryBook(diaries: [
|
||||
for (var i = 0; i < kMaxDiaries - 1; i++) _diary('d$i', ['pet-2']),
|
||||
_diary('gone', ['pet-1']),
|
||||
]);
|
||||
expect(full.atMax(), isTrue);
|
||||
expect(full.atMax({'pet-1'}), isFalse);
|
||||
});
|
||||
|
||||
test('아이가 담긴 다이어리와 그 기록 수', () {
|
||||
expect(book.diariesOf('pet-1').map((d) => d.id), ['only', 'mixed']);
|
||||
expect(book.postCountOf('pet-1'), 2);
|
||||
});
|
||||
|
||||
test('최근 기록은 고른 다이어리에서만 뽑을 수 있다', () {
|
||||
expect(book.latest(3).map((p) => p.id), ['c', 'b', 'a']);
|
||||
expect(book.latest(3, diaryIds: {'mixed', 'other'}).map((p) => p.id), ['c', 'b']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/utils/hour_minute.dart';
|
||||
import 'package:tma_app/features/archive/presentation/pages/archive_list_page.dart';
|
||||
import 'package:tma_app/features/archive/presentation/pages/pet_archive_move_page.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet_archive.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart';
|
||||
import 'package:tma_app/features/schedule/domain/event_category.dart';
|
||||
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
final DateTime _today = DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day);
|
||||
|
||||
final Diary _diary = Diary(
|
||||
id: 'd1',
|
||||
name: '콩이 성장일기',
|
||||
targetIds: const ['pet-1'],
|
||||
ribbon: kRibbonColors.first,
|
||||
tint: kDiaryTints.first,
|
||||
);
|
||||
|
||||
final DiaryPost _post = DiaryPost(
|
||||
id: 'p1',
|
||||
diaryId: 'd1',
|
||||
date: DateTime(2026, 9, 1),
|
||||
createdAt: DateTime(2026, 9, 1, 9),
|
||||
text: '공원 산책',
|
||||
mood: Mood.happy,
|
||||
);
|
||||
|
||||
final ScheduleEvent _walk = ScheduleEvent(
|
||||
id: 'e1',
|
||||
date: _today,
|
||||
details: const EventDetails(
|
||||
title: '저녁 산책',
|
||||
kind: EventKind.general,
|
||||
category: EventCategory.walk,
|
||||
targetIds: ['pet-1'],
|
||||
start: HourMinute(19, 0),
|
||||
),
|
||||
);
|
||||
|
||||
final PetArchive _archive = PetArchive(
|
||||
from: DateTime(2021, 9, 14),
|
||||
to: DateTime(2026, 9, 14),
|
||||
archivedAt: DateTime(2026, 9, 14, 21),
|
||||
memo: '고마웠어 콩이야',
|
||||
);
|
||||
|
||||
/// 콩이 + 다이어리·기록·명함·일정. [archived] 면 콩이는 이미 추억 보관함에 있다
|
||||
Map<String, Object> _prefs({bool archived = false}) {
|
||||
final pet = archived ? samplePet().withArchive(_archive) : samplePet();
|
||||
return {
|
||||
'pets.v1': jsonEncode([pet.toJson()]),
|
||||
'diary.diaries.v1': jsonEncode([_diary.toJson()]),
|
||||
'diary.posts.v1': jsonEncode([_post.toJson()]),
|
||||
'petCards.v1': jsonEncode([PetCard(id: 'c1', petId: 'pet-1', style: CardStyle.initial()).toJson()]),
|
||||
'schedule.events.v1': jsonEncode([_walk.toJson()]),
|
||||
};
|
||||
}
|
||||
|
||||
/// 목록 아래쪽 줄은 스크롤해야 만들어진다 — 보일 때까지 내려서 누른다
|
||||
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
|
||||
await tester.scrollUntilVisible(finder, 200, scrollable: find.byType(Scrollable).first);
|
||||
await tester.pump();
|
||||
await tester.tap(finder);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> _openMyPage(WidgetTester tester) async {
|
||||
await tester.tap(find.bySemanticsLabel('마이페이지'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> _openArchive(WidgetTester tester) async {
|
||||
await _openMyPage(tester);
|
||||
await _tapVisible(tester, find.text('보관된 아이'));
|
||||
expect(find.byType(ArchiveListPage), findsOneWidget);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('반려동물 정보에서 추억 보관함으로 옮기면 일정은 지워지고 홈·책장에서 빠진다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester, extra: _prefs());
|
||||
await _openMyPage(tester);
|
||||
await tester.tap(find.bySemanticsLabel('반려동물 1마리'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('콩이'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.text('추억 보관함으로'));
|
||||
expect(find.text('콩이를 추억 보관함으로'), findsOneWidget);
|
||||
await tester.tap(find.text('보관하기'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(PetArchiveMovePage), findsOneWidget);
|
||||
expect(find.text('콩이와 함께한 기간을 알려주세요'), findsOneWidget);
|
||||
expect(find.text('2021.09.14'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), '고마웠어');
|
||||
await tester.tap(find.text('추억 보관함으로 옮기기'));
|
||||
await settle(tester);
|
||||
|
||||
// 마이페이지로 돌아와 보관함에 한 마리
|
||||
expect(find.text('추억보관함에 보관했어요.'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('반려동물 0마리'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('보관된 아이'), 200, scrollable: find.byType(Scrollable).first);
|
||||
expect(find.text('1마리'), findsOneWidget);
|
||||
|
||||
final saved = prefs.getString('pets.v1')!;
|
||||
expect(saved, contains('"archive"'));
|
||||
expect(saved, contains('고마웠어'));
|
||||
// 콩이만의 일정은 지운다
|
||||
expect(prefs.getString('schedule.events.v1'), isNot(contains('저녁 산책')));
|
||||
|
||||
// 홈에는 아이 등록 안내, 다이어리 책장에서도 빠진다
|
||||
await tester.tap(find.bySemanticsLabel('홈'));
|
||||
await settle(tester);
|
||||
expect(find.text('우리 아이를 등록해 주세요'), findsOneWidget);
|
||||
await tester.tap(find.bySemanticsLabel('일정·기록'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('다이어리'));
|
||||
await settle(tester);
|
||||
expect(find.text('콩이 성장일기'), findsNothing);
|
||||
expect(find.text('0/6'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('보관된 아이를 누르면 함께한 기간과 메모, 원래 정보·명함·다이어리를 다시 본다', (tester) async {
|
||||
await launchSignedIn(tester, extra: _prefs(archived: true));
|
||||
await _openArchive(tester);
|
||||
expect(find.text('1마리'), findsOneWidget);
|
||||
expect(find.text('강아지 · 말티즈 · 기록 1개'), findsOneWidget);
|
||||
expect(find.text('2021.09.14 ~ 2026.09.14'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('콩이'));
|
||||
await settle(tester);
|
||||
expect(find.text('5년 함께했어요'), findsOneWidget);
|
||||
expect(find.text('고마웠어 콩이야'), findsOneWidget);
|
||||
expect(find.text('함께한 펫 명함 1개'), findsOneWidget);
|
||||
|
||||
await _tapVisible(tester, find.text('기본·건강 정보를 다시 봐요.'));
|
||||
expect(find.text('콩이의 정보'), findsOneWidget);
|
||||
expect(find.text('기본 정보'), findsOneWidget);
|
||||
await tester.tap(find.bySemanticsLabel('뒤로'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.text('함께한 펫 명함 1개'));
|
||||
expect(find.byType(NameCard), findsOneWidget);
|
||||
await tester.tap(find.byType(NameCard));
|
||||
await settle(tester);
|
||||
expect(find.byType(PetIdCard), findsOneWidget);
|
||||
await tester.tap(find.bySemanticsLabel('뒤로'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.bySemanticsLabel('뒤로'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.text('함께한 날들을 다시 봐요.'));
|
||||
expect(find.text('다이어리 · 1권'), findsOneWidget);
|
||||
await tester.tap(find.bySemanticsLabel('콩이 성장일기, 기록 1개'));
|
||||
await settle(tester);
|
||||
|
||||
// 읽기만 — 쓰기·설정 버튼이 없다
|
||||
expect(find.byType(DiaryPage), findsOneWidget);
|
||||
expect(find.text('공원 산책'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('다이어리 쓰기'), findsNothing);
|
||||
expect(find.bySemanticsLabel('다이어리 설정'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('다시 불러오면 보관함에서 빠지고 나의 반려동물로 돌아온다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester, extra: _prefs(archived: true));
|
||||
await _openArchive(tester);
|
||||
await tester.tap(find.text('콩이'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.text('콩이 다시 불러오기'));
|
||||
expect(find.text('다시 불러왔어요'), findsOneWidget);
|
||||
expect(find.byType(ArchiveListPage), findsOneWidget);
|
||||
expect(find.text('보관된 아이가 없어요'), findsOneWidget);
|
||||
expect(prefs.getString('pets.v1'), isNot(contains('"archive"')));
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('뒤로'));
|
||||
await settle(tester);
|
||||
expect(find.bySemanticsLabel('반려동물 1마리'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -94,7 +94,7 @@ void main() {
|
||||
|
||||
test('다이어리는 6권까지', () {
|
||||
final book = DiaryBook(diaries: [for (var i = 0; i < kMaxDiaries; i++) _diary('d$i')]);
|
||||
expect(book.atMax, isTrue);
|
||||
expect(book.atMax(), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -66,5 +66,36 @@ void main() {
|
||||
expect(find.text('명함 만들기'), findsNothing);
|
||||
expect(find.bySemanticsLabel('콩이 명함 공유'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('우리 아이를 꾹 눌러 끌면 순서가 바뀌고 저장된다', (tester) async {
|
||||
final pets = [samplePet(id: 'pet-1', name: '콩이'), samplePet(id: 'pet-2', name: '보리')];
|
||||
final prefs = await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([for (final p in pets) p.toJson()])});
|
||||
|
||||
final from = tester.getCenter(find.text('콩이'));
|
||||
final to = tester.getCenter(find.text('보리'));
|
||||
// 320ms 이상 누르면 들어 올리고, 끌어서 보리 자리에 놓는다
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await gesture.moveTo(Offset.lerp(from, to, .5)!);
|
||||
await tester.pump();
|
||||
await gesture.moveTo(to);
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await settle(tester);
|
||||
|
||||
expect(tester.getCenter(find.text('보리')).dx, lessThan(tester.getCenter(find.text('콩이')).dx));
|
||||
final saved = (jsonDecode(prefs.getString('pets.v1')!) as List<Object?>).cast<Map<String, Object?>>();
|
||||
expect(saved.map((p) => p['name']), ['보리', '콩이']);
|
||||
});
|
||||
|
||||
testWidgets('짧게 누르면 순서는 그대로 두고 그 아이를 고른다', (tester) async {
|
||||
final pets = [samplePet(id: 'pet-1', name: '콩이'), samplePet(id: 'pet-2', name: '보리')];
|
||||
final prefs = await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([for (final p in pets) p.toJson()])});
|
||||
|
||||
await tester.tap(find.text('보리'));
|
||||
await settle(tester);
|
||||
expect(find.text('보리의 펫 명함이 아직 없어요', findRichText: true), findsOneWidget);
|
||||
final saved = (jsonDecode(prefs.getString('pets.v1')!) as List<Object?>).cast<Map<String, Object?>>();
|
||||
expect(saved.map((p) => p['name']), ['콩이', '보리']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
|
||||
final DateTime _evening = DateTime(2026, 9, 28, 20, 30);
|
||||
|
||||
Diary _diary(String id, {Set<DiaryNotice> muted = const {}}) => Diary(
|
||||
id: id,
|
||||
name: '$id 일기',
|
||||
targetIds: const ['pet-1'],
|
||||
ribbon: kRibbonColors.first,
|
||||
tint: kDiaryTints.first,
|
||||
mutedNotices: muted,
|
||||
);
|
||||
|
||||
DiaryPost _post(String diaryId, DateTime date) => DiaryPost(
|
||||
id: 'p-$diaryId',
|
||||
diaryId: diaryId,
|
||||
date: DateTime(date.year, date.month, date.day),
|
||||
createdAt: date,
|
||||
text: '산책',
|
||||
mood: Mood.happy,
|
||||
);
|
||||
|
||||
/// 저장소에 다이어리·기록을 넣고, 시계를 [now] 로 고정한 컨테이너
|
||||
Future<ProviderContainer> _container({
|
||||
DateTime? now,
|
||||
List<Diary> diaries = const [],
|
||||
List<DiaryPost> posts = const [],
|
||||
Map<String, Object> extra = const {},
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'diary.diaries.v1': jsonEncode([for (final d in diaries) d.toJson()]),
|
||||
'diary.posts.v1': jsonEncode([for (final p in posts) p.toJson()]),
|
||||
...extra,
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final at = now ?? _evening;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
clockProvider.overrideWithValue(() => at),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(diaryProvider.future);
|
||||
return container;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('NoticeDay.of', () {
|
||||
final now = DateTime(2026, 9, 28, 9);
|
||||
|
||||
test('같은 날은 오늘, 하루 전은 어제, 일주일 안은 최근 7일', () {
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 28, 0, 5), now), NoticeDay.today);
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 27, 23, 50), now), NoticeDay.yesterday);
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 22, 12), now), NoticeDay.week);
|
||||
});
|
||||
|
||||
test('일주일이 넘은 알림은 묶음이 없다', () {
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 21, 12), now), isNull);
|
||||
});
|
||||
|
||||
test('달이 바뀌어도 날짜 차이로 묶는다', () {
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 30), DateTime(2026, 10, 1)), NoticeDay.yesterday);
|
||||
});
|
||||
});
|
||||
|
||||
group('relativeTimeLabel', () {
|
||||
final now = DateTime(2026, 9, 28, 20, 30);
|
||||
|
||||
test('오늘은 방금 전 · N분 전 · N시간 전', () {
|
||||
expect(relativeTimeLabel(now.subtract(const Duration(seconds: 20)), now), '방금 전');
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 28, 20), now), '30분 전');
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 28, 17, 10), now), '3시간 전');
|
||||
});
|
||||
|
||||
test('지난 날은 어제 · N일 전', () {
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 27, 23), now), '어제');
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 25, 9), now), '3일 전');
|
||||
});
|
||||
});
|
||||
|
||||
group('noticesProvider — 기록 알림', () {
|
||||
test('저녁 8시가 지나도록 오늘 기록이 없는 다이어리마다 알린다', () async {
|
||||
final c = await _container(diaries: [_diary('d1'), _diary('d2')]);
|
||||
final notices = c.read(noticesProvider);
|
||||
expect(notices.map((n) => n.diaryId), unorderedEquals(['d1', 'd2']));
|
||||
expect(notices.first.type, NoticeType.reminder);
|
||||
expect(notices.first.text, kDiaryReminderText);
|
||||
expect(notices.first.at, DateTime(2026, 9, 28, kDiaryReminderHour));
|
||||
});
|
||||
|
||||
test('저녁 8시 전에는 알리지 않는다', () async {
|
||||
final c = await _container(now: DateTime(2026, 9, 28, 19, 59), diaries: [_diary('d1')]);
|
||||
expect(c.read(noticesProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('오늘 기록을 남긴 다이어리는 알리지 않는다', () async {
|
||||
final c = await _container(
|
||||
diaries: [_diary('d1'), _diary('d2')],
|
||||
posts: [_post('d1', DateTime(2026, 9, 28, 9))],
|
||||
);
|
||||
expect(c.read(noticesProvider).map((n) => n.diaryId), ['d2']);
|
||||
});
|
||||
|
||||
test('어제 기록은 오늘 기록으로 치지 않는다', () async {
|
||||
final c = await _container(diaries: [_diary('d1')], posts: [_post('d1', DateTime(2026, 9, 27, 21))]);
|
||||
expect(c.read(noticesProvider), hasLength(1));
|
||||
});
|
||||
|
||||
test('다이어리 설정에서 기록 알림을 끄면 알리지 않는다', () async {
|
||||
final c = await _container(diaries: [_diary('d1', muted: {DiaryNotice.reminder})]);
|
||||
expect(c.read(noticesProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('마이페이지 일정·다이어리 알림을 끄면 모두 끈다', () async {
|
||||
final c = await _container(
|
||||
diaries: [_diary('d1')],
|
||||
extra: {AppSetting.scheduleNotification.key: false},
|
||||
);
|
||||
expect(c.read(noticesProvider), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('새 알림 표시', () {
|
||||
test('한 번도 보지 않았으면 새 알림이 있다', () async {
|
||||
final c = await _container(diaries: [_diary('d1')]);
|
||||
expect(c.read(hasUnreadNoticeProvider), isTrue);
|
||||
});
|
||||
|
||||
test('알림 화면을 본 뒤에는 새 알림이 없고, 본 시각이 저장된다', () async {
|
||||
final c = await _container(diaries: [_diary('d1')]);
|
||||
await c.read(noticeSeenProvider.notifier).markSeen(_evening);
|
||||
expect(c.read(hasUnreadNoticeProvider), isFalse);
|
||||
|
||||
final prefs = c.read(sharedPreferencesProvider);
|
||||
expect(DateTime.parse(prefs.getString('notice.seenAt.v1')!), _evening);
|
||||
});
|
||||
|
||||
test('다음 날의 기록 알림은 다시 새 알림이다', () async {
|
||||
final c = await _container(
|
||||
now: DateTime(2026, 9, 29, 20, 10),
|
||||
diaries: [_diary('d1')],
|
||||
extra: {'notice.seenAt.v1': _evening.toIso8601String()},
|
||||
);
|
||||
expect(c.read(hasUnreadNoticeProvider), isTrue);
|
||||
});
|
||||
|
||||
test('알림이 없으면 새 알림도 없다', () async {
|
||||
final c = await _container(now: DateTime(2026, 9, 28, 10), diaries: [_diary('d1')]);
|
||||
expect(c.read(hasUnreadNoticeProvider), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/pages/notifications_page.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
final Diary _diary = Diary(
|
||||
id: 'd1',
|
||||
name: '콩이 성장일기',
|
||||
targetIds: const ['pet-1'],
|
||||
ribbon: kRibbonColors.first,
|
||||
tint: kDiaryTints.first,
|
||||
);
|
||||
|
||||
final Map<String, Object> _prefs = {
|
||||
'pets.v1': jsonEncode([samplePet().toJson()]),
|
||||
'diary.diaries.v1': jsonEncode([_diary.toJson()]),
|
||||
};
|
||||
|
||||
/// 오늘 [hour]:30 으로 고정한 시계 — 앱의 다른 화면은 실제 날짜를 쓰므로 날짜는 오늘로 맞춘다
|
||||
DateTime _today(int hour) {
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, now.month, now.day, hour, 30);
|
||||
}
|
||||
|
||||
Future<void> _launchAt(WidgetTester tester, int hour, {Map<String, Object> extra = const {}}) async {
|
||||
final at = _today(hour);
|
||||
await launchSignedIn(
|
||||
tester,
|
||||
extra: {..._prefs, ...extra},
|
||||
overrides: [clockProvider.overrideWithValue(() => at)],
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('저녁에 기록이 없으면 종에 새 알림 표시가 뜨고, 알림 화면에서 기록 알림을 본다', (tester) async {
|
||||
await _launchAt(tester, 20);
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
|
||||
await settle(tester);
|
||||
expect(find.byType(NotificationsPage), findsOneWidget);
|
||||
expect(find.text('우리 가족 다이어리 소식'), findsOneWidget);
|
||||
expect(find.text('오늘'), findsOneWidget);
|
||||
expect(find.text(kDiaryReminderText, findRichText: true), findsOneWidget);
|
||||
expect(find.text('콩이 성장일기 · 30분 전'), findsOneWidget);
|
||||
|
||||
// 보고 나오면 종의 새 알림 표시가 사라진다
|
||||
await tester.tap(find.bySemanticsLabel('뒤로'));
|
||||
await settle(tester);
|
||||
expect(find.bySemanticsLabel('알림'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('기록 알림을 누르면 그 다이어리로 간다', (tester) async {
|
||||
await _launchAt(tester, 21);
|
||||
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text(kDiaryReminderText, findRichText: true));
|
||||
await settle(tester);
|
||||
expect(find.byType(DiaryPage), findsOneWidget);
|
||||
expect(find.text('콩이 성장일기'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('알림이 없으면 빈 안내를 보여 준다', (tester) async {
|
||||
await _launchAt(tester, 10);
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('알림'));
|
||||
await settle(tester);
|
||||
expect(find.text('새로운 알림이 없어요'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('다이어리 설정에서 기록 알림을 끄면 저녁에도 알리지 않는다', (tester) async {
|
||||
final muted = _diary.copyWith(mutedNotices: {DiaryNotice.reminder});
|
||||
await _launchAt(tester, 20, extra: {'diary.diaries.v1': jsonEncode([muted.toJson()])});
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/storage/album_saver.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
/// 앨범 대신 받은 이름만 모아 두는 저장기
|
||||
class _FakeAlbum implements AlbumSaver {
|
||||
_FakeAlbum({this.ok = true});
|
||||
|
||||
final bool ok;
|
||||
final saved = <String>[];
|
||||
|
||||
@override
|
||||
Future<bool> savePng(Uint8List bytes, String name) async {
|
||||
saved.add(name);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> _prefs() => {
|
||||
'pets.v1': jsonEncode([samplePet().toJson()]),
|
||||
'petCards.v1': jsonEncode([PetCard(id: 'c1', petId: 'pet-1', style: CardStyle.initial()).toJson()]),
|
||||
};
|
||||
|
||||
Future<_FakeAlbum> _openShare(WidgetTester tester, {bool ok = true}) async {
|
||||
final album = _FakeAlbum(ok: ok);
|
||||
await launchSignedIn(tester, extra: _prefs(), overrides: [albumSaverProvider.overrideWithValue(album)]);
|
||||
await tester.tap(find.bySemanticsLabel('콩이 명함 공유'));
|
||||
await settle(tester);
|
||||
expect(find.text('콩이 펫 명함 공유'), findsOneWidget);
|
||||
return album;
|
||||
}
|
||||
|
||||
/// 명함 이미지를 떠 내는 일은 실제 비동기라 runAsync 안에서 누른다
|
||||
Future<void> _tapAndWait(WidgetTester tester, Finder finder) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(finder);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('이미지로 저장하면 앨범에 저장하고 시트를 닫은 뒤 알린다', (tester) async {
|
||||
final album = await _openShare(tester);
|
||||
await _tapAndWait(tester, find.bySemanticsLabel('이미지로 저장'));
|
||||
|
||||
expect(album.saved, ['rup_콩이_명함']);
|
||||
expect(find.text('콩이 펫 명함 공유'), findsNothing);
|
||||
expect(find.text('이미지로 저장했어요'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('QR 이미지 저장은 QR 창과 공유 시트를 함께 닫는다', (tester) async {
|
||||
final album = await _openShare(tester);
|
||||
await tester.tap(find.bySemanticsLabel('QR 코드 공유'));
|
||||
await settle(tester);
|
||||
expect(find.text('펫 명함 QR'), findsOneWidget);
|
||||
|
||||
await _tapAndWait(tester, find.text('QR 이미지 저장'));
|
||||
expect(album.saved, ['rup_콩이_QR']);
|
||||
expect(find.text('펫 명함 QR'), findsNothing);
|
||||
expect(find.text('콩이 펫 명함 공유'), findsNothing);
|
||||
expect(find.text('QR 코드를 저장했어요'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('사진 접근을 거절해 저장하지 못하면 알려 준다', (tester) async {
|
||||
await _openShare(tester, ok: false);
|
||||
await _tapAndWait(tester, find.bySemanticsLabel('이미지로 저장'));
|
||||
expect(find.text('저장하지 못했어요. 사진 접근 권한을 확인해 주세요'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,8 @@ 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/domain/pet_species.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
PetInput _input({String name = '콩이', double weight = 3.2}) => PetInput(
|
||||
name: name,
|
||||
species: '강아지',
|
||||
@@ -138,4 +140,20 @@ void main() {
|
||||
expect(await repo.fetchPets(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('reorderPets — 우리 아이 순서 바꾸기', () {
|
||||
final pets = [samplePet(id: 'a', name: '콩이'), samplePet(id: 'b', name: '보리'), samplePet(id: 'c', name: '초코')];
|
||||
|
||||
test('준 순서대로 줄 세운다', () {
|
||||
expect(reorderPets(pets, ['c', 'a', 'b']).map((p) => p.id), ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('목록에 없는 아이(추억 보관함 등)는 원래 순서대로 뒤에 둔다', () {
|
||||
expect(reorderPets(pets, ['b', 'a']).map((p) => p.id), ['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('없는 id 는 건너뛴다', () {
|
||||
expect(reorderPets(pets, ['x', 'b']).map((p) => p.id), ['b', 'a', 'c']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,4 +138,77 @@ void main() {
|
||||
expect(find.text('예방접종'), findsOneWidget);
|
||||
expect(find.text('예약된 일정이 없어요'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)으로 저장된다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester, extra: _withPet());
|
||||
await _openRecords(tester);
|
||||
await tester.tap(find.text('일정 추가'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '영양제');
|
||||
await _tapVisible(tester, find.bySemanticsLabel('기간 설정'));
|
||||
await _tapVisible(tester, find.text('2회'));
|
||||
await _tapVisible(tester, find.text('일정 저장'));
|
||||
|
||||
final saved = jsonDecode(prefs.getString('schedule.events.v1')!) as List<Object?>;
|
||||
final starts = {for (final e in saved.cast<Map<String, Object?>>()) e['start']};
|
||||
expect(starts, {'08:00', '20:00'});
|
||||
});
|
||||
|
||||
testWidgets('예약 분류를 골랐다가 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다', (tester) async {
|
||||
await launchSignedIn(tester, extra: _withPet());
|
||||
await _openRecords(tester);
|
||||
await tester.tap(find.text('일정 추가'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.text('목욕'));
|
||||
await _tapVisible(tester, find.bySemanticsLabel('병원·검진'));
|
||||
expect(tester.getSemantics(find.bySemanticsLabel('병원·검진')), isSemantics(isSelected: true));
|
||||
|
||||
// 이름 칸에도 '목욕' 이 들어 있으므로 위쪽의 칩을 누른다
|
||||
await _tapVisible(tester, find.text('목욕').first);
|
||||
expect(tester.getSemantics(find.bySemanticsLabel('병원·검진')), isSemantics(isSelected: false));
|
||||
});
|
||||
|
||||
testWidgets('주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다', (tester) async {
|
||||
await launchSignedIn(tester, extra: _withPet());
|
||||
await _openRecords(tester);
|
||||
await tester.tap(find.text('일정 추가'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.bySemanticsLabel('반복 일정'));
|
||||
await _tapVisible(tester, find.text('주기 직접 설정'));
|
||||
expect(find.text('2주 마다 반복'), findsWidgets);
|
||||
|
||||
await _tapVisible(tester, find.text('평일만'));
|
||||
expect(find.text('평일마다 반복'), findsOneWidget);
|
||||
await _tapVisible(tester, find.text('주기 직접 설정'));
|
||||
expect(find.text('2주 마다 반복'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('시간 휠의 가운데 숫자를 누르면 직접 입력할 수 있다', (tester) async {
|
||||
await launchSignedIn(tester, extra: _withPet());
|
||||
await _openRecords(tester);
|
||||
await tester.tap(find.text('일정 추가'));
|
||||
await settle(tester);
|
||||
|
||||
await _tapVisible(tester, find.text('시작'));
|
||||
expect(find.text('오후 2:00 선택'), findsOneWidget);
|
||||
|
||||
// 가운데(선택된) 시 — 02
|
||||
await tester.tap(find.text('02').first);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField).last, '7');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await settle(tester);
|
||||
expect(find.text('오후 7:00 선택'), findsOneWidget);
|
||||
|
||||
// 범위를 넘으면 가장 가까운 값으로 (분 99 → 59)
|
||||
await tester.tap(find.text('00').first);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField).last, '99');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await settle(tester);
|
||||
expect(find.text('오후 7:59 선택'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/app.dart';
|
||||
@@ -28,8 +29,12 @@ Pet samplePet({String id = 'pet-1', String name = '콩이'}) => Pet.fromInput(
|
||||
);
|
||||
|
||||
/// 시안 크기 화면에서 로그인한 사용자로 앱을 띄우고 스플래시를 지나 홈까지 간다.
|
||||
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다.
|
||||
Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Object> extra = const {}}) async {
|
||||
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다. [overrides] 로 시계 등을 바꿔 끼운다.
|
||||
Future<SharedPreferences> launchSignedIn(
|
||||
WidgetTester tester, {
|
||||
Map<String, Object> extra = const {},
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(402, 874);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
@@ -38,7 +43,7 @@ Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Objec
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs), ...overrides],
|
||||
child: const RupApp(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
@@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
gal
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user