feat(flutter): 다이어리 책장·기록·댓글·달력 보기와 홈 최근 기록 구현

일정 관리 다음 흐름 — 일정·기록 탭의 다이어리.
- 책장: 표지 그리드(최대 6권), 반투명 책갈피 리본, 새 다이어리 만들기(표지·이름·대상·리본 색)
- 다이어리 안: 기록 피드(모두/사진/글) · 검색 · 달력 보기(날마다 기분 표정) · 하루 한 번 쓰기 버튼
- 기록하기: 사진 · 오늘 기분 6종 · 글 / 기록 수정·삭제 / 댓글 작성·수정·삭제
- 다이어리 설정: 정보 수정, 알림 스위치(값만 저장), 다이어리 제거(기록·댓글 함께)
- 홈 다이어리: 최근 기록 3개를 사진/글 타일로, 누르면 그 다이어리로
- 아이를 지우면 다이어리 대상에서만 떼고, 회원탈퇴는 다이어리도 지운다
- 기록은 기기에 저장한다 (공유 다이어리 서버가 생기면 DiaryRepository 구현을 바꾼다)

시안과 다른 점: 공유 다이어리(초대 코드·QR·멤버·참여)는 서버가 필요해 안내만 둔다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:58:09 +09:00
parent 76e9fc1af1
commit d7f46d174a
29 changed files with 3295 additions and 10 deletions
@@ -0,0 +1,46 @@
import 'dart:convert';
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/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
/// 다이어리 저장소 계약 — 공유 다이어리(서버)가 생기면 API 구현으로 바꾼다.
abstract interface class DiaryRepository {
Future<DiaryBook> fetch();
Future<void> save(DiaryBook book);
}
const String _kDiariesKey = 'diary.diaries.v1';
const String _kPostsKey = 'diary.posts.v1';
const String _kCommentsKey = 'diary.comments.v1';
List<Map<String, Object?>> _decode(String? raw) =>
raw == null ? const [] : (jsonDecode(raw) as List<Object?>).whereType<Map<String, Object?>>().toList();
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
class LocalDiaryRepository implements DiaryRepository {
LocalDiaryRepository(this._prefs);
final SharedPreferences _prefs;
@override
Future<DiaryBook> fetch() async => DiaryBook(
diaries: _decode(_prefs.getString(_kDiariesKey)).map(Diary.fromJson).toList(),
posts: _decode(_prefs.getString(_kPostsKey)).map(DiaryPost.fromJson).toList(),
comments: _decode(_prefs.getString(_kCommentsKey)).map(DiaryComment.fromJson).toList(),
);
@override
Future<void> save(DiaryBook book) async {
await _prefs.setString(_kDiariesKey, jsonEncode([for (final d in book.diaries) d.toJson()]));
await _prefs.setString(_kPostsKey, jsonEncode([for (final p in book.posts) p.toJson()]));
await _prefs.setString(_kCommentsKey, jsonEncode([for (final c in book.comments) c.toJson()]));
}
}
final diaryRepositoryProvider = Provider<DiaryRepository>((ref) {
return LocalDiaryRepository(ref.watch(sharedPreferencesProvider));
});
@@ -0,0 +1,200 @@
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/diary/domain/mood.dart';
/// 다이어리는 최대 6권 (시안 MAX_DIARIES)
const int kMaxDiaries = 6;
const int kDiaryNameMaxLength = 18;
const int kCommentMaxLength = 50;
/// 다이어리 이름을 비워 두면 쓰는 이름
const String kDefaultDiaryName = '새 다이어리';
/// 기록한 사람 — 지금은 나 하나 (공유 다이어리가 생기면 회원 id)
const String kMeAuthorId = 'me';
/// 책갈피 리본 색 선택지 (시안 RIBBON_SWATCHES)
const List<Color> kRibbonColors = [
Color(0xFFFFFFFF), Color(0xFF2B2622), Color(0xFFFF7500),
Color(0xFF4DA6E0), Color(0xFF3FB58C), Color(0xFFFF7AA0),
];
/// 표지 사진이 없을 때 바탕색 — 만들 때 하나를 고른다
const List<Color> kDiaryTints = [
Color(0xFFFF8A5B), Color(0xFF6FB8E8), Color(0xFFB07CF0),
Color(0xFF3FB58C), Color(0xFFFF7AA0), Color(0xFFFFAA4D),
];
/// 다이어리별 알림 — 새 글 / 내 글에 새 댓글 / 오늘 기록 잊지 않게
enum DiaryNotice {
newPost,
newComment,
reminder;
}
/// 다이어리 한 권
class Diary {
const Diary({
required this.id,
required this.name,
required this.targetIds,
required this.ribbon,
required this.tint,
this.coverPath,
this.mutedNotices = const {},
});
final String id;
final String name;
/// 누구의 다이어리인지 — 반려동물 id 또는 나(self)
final List<String> targetIds;
final Color ribbon;
final Color tint;
final String? coverPath;
/// 끈 알림 (기본은 모두 켜짐)
final Set<DiaryNotice> mutedNotices;
bool notifies(DiaryNotice n) => !mutedNotices.contains(n);
Diary copyWith({
String? name,
List<String>? targetIds,
Color? ribbon,
String? coverPath,
Set<DiaryNotice>? mutedNotices,
}) =>
Diary(
id: id,
name: name ?? this.name,
targetIds: targetIds ?? this.targetIds,
ribbon: ribbon ?? this.ribbon,
tint: tint,
coverPath: coverPath ?? this.coverPath,
mutedNotices: mutedNotices ?? this.mutedNotices,
);
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'targets': targetIds,
'ribbon': hexOf(ribbon),
'tint': hexOf(tint),
'cover': coverPath,
'muted': [for (final n in mutedNotices) n.name],
};
factory Diary.fromJson(Map<String, Object?> json) => Diary(
id: json['id']! as String,
name: json['name']! as String,
targetIds: (json['targets'] as List?)?.whereType<String>().toList() ?? const [],
ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first,
tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first,
coverPath: json['cover'] as String?,
mutedNotices: {
for (final n in (json['muted'] as List?)?.whereType<String>() ?? const <String>[])
?DiaryNotice.values.where((v) => v.name == n).firstOrNull,
},
);
}
/// 다이어리의 기록 한 건
class DiaryPost {
const DiaryPost({
required this.id,
required this.diaryId,
required this.date,
required this.createdAt,
required this.text,
this.mood,
this.photoPath,
this.authorId = kMeAuthorId,
});
final String id;
final String diaryId;
/// 기록한 날 (시각 없음) — 하루 한 번 쓰기와 달력에 쓴다
final DateTime date;
/// 같은 날 기록끼리 순서를 정한다
final DateTime createdAt;
final String text;
final Mood? mood;
final String? photoPath;
final String authorId;
bool get hasPhoto => photoPath != null;
DiaryPost copyWith({String? text}) => DiaryPost(
id: id,
diaryId: diaryId,
date: date,
createdAt: createdAt,
text: text ?? this.text,
mood: mood,
photoPath: photoPath,
authorId: authorId,
);
Map<String, Object?> toJson() => {
'id': id,
'diary': diaryId,
'date': toIsoDate(date),
'createdAt': createdAt.toIso8601String(),
'text': text,
'mood': mood?.name,
'photo': photoPath,
'author': authorId,
};
factory DiaryPost.fromJson(Map<String, Object?> json) => DiaryPost(
id: json['id']! as String,
diaryId: json['diary']! as String,
date: parseIsoDate(json['date']! as String),
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime(2000),
text: json['text'] as String? ?? '',
mood: Mood.byName(json['mood'] as String?),
photoPath: json['photo'] as String?,
authorId: json['author'] as String? ?? kMeAuthorId,
);
}
/// 기록에 단 댓글
class DiaryComment {
const DiaryComment({
required this.id,
required this.postId,
required this.text,
required this.createdAt,
this.authorId = kMeAuthorId,
});
final String id;
final String postId;
final String text;
final DateTime createdAt;
final String authorId;
DiaryComment copyWith({String? text}) =>
DiaryComment(id: id, postId: postId, text: text ?? this.text, createdAt: createdAt, authorId: authorId);
Map<String, Object?> toJson() => {
'id': id,
'post': postId,
'text': text,
'createdAt': createdAt.toIso8601String(),
'author': authorId,
};
factory DiaryComment.fromJson(Map<String, Object?> json) => DiaryComment(
id: json['id']! as String,
postId: json['post']! as String,
text: json['text'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime(2000),
authorId: json['author'] as String? ?? kMeAuthorId,
);
}
@@ -0,0 +1,85 @@
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
/// 다이어리 전체 — 다이어리 · 기록 · 댓글. 바꾸는 메서드는 새 객체를 돌려준다.
class DiaryBook {
const DiaryBook({this.diaries = const [], this.posts = const [], this.comments = const []});
static const DiaryBook empty = DiaryBook();
final List<Diary> diaries;
final List<DiaryPost> posts;
final List<DiaryComment> comments;
bool get atMax => diaries.length >= kMaxDiaries;
Diary? diary(String id) => diaries.where((d) => d.id == id).firstOrNull;
/// 새 기록이 위로 — 날짜, 같은 날이면 쓴 순서
static int _newestFirst(DiaryPost a, DiaryPost b) {
final byDate = b.date.compareTo(a.date);
return byDate != 0 ? byDate : b.createdAt.compareTo(a.createdAt);
}
List<DiaryPost> postsOf(String diaryId) =>
posts.where((p) => p.diaryId == diaryId).toList()..sort(_newestFirst);
int countOf(String diaryId) => posts.where((p) => p.diaryId == diaryId).length;
/// 다이어리는 사람마다 하루 한 번 쓸 수 있다
bool wroteOn(String diaryId, DateTime day, {String authorId = kMeAuthorId}) {
final d = dateOnly(day);
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();
List<DiaryComment> commentsOf(String postId) =>
comments.where((c) => c.postId == postId).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
DiaryBook copyWith({List<Diary>? diaries, List<DiaryPost>? posts, List<DiaryComment>? comments}) => DiaryBook(
diaries: diaries ?? this.diaries,
posts: posts ?? this.posts,
comments: comments ?? this.comments,
);
// ── 다이어리 ────────────────────────────────────────────
DiaryBook addDiary(Diary d) => copyWith(diaries: [...diaries, d]);
DiaryBook updateDiary(Diary d) => copyWith(diaries: [for (final x in diaries) x.id == d.id ? d : x]);
/// 다이어리를 지우면 그 안의 기록·댓글도 모두 지운다
DiaryBook deleteDiary(String id) {
final gone = posts.where((p) => p.diaryId == id).map((p) => p.id).toSet();
return DiaryBook(
diaries: diaries.where((d) => d.id != id).toList(),
posts: posts.where((p) => p.diaryId != id).toList(),
comments: comments.where((c) => !gone.contains(c.postId)).toList(),
);
}
/// 아이를 지우면 다이어리의 대상에서만 뗀다 (기록은 남긴다)
DiaryBook dropTarget(String targetId) => copyWith(diaries: [
for (final d in diaries) d.copyWith(targetIds: d.targetIds.where((id) => id != targetId).toList()),
]);
// ── 기록 ───────────────────────────────────────────────
DiaryBook addPost(DiaryPost p) => copyWith(posts: [...posts, p]);
DiaryBook updatePostText(String id, String text) =>
copyWith(posts: [for (final p in posts) p.id == id ? p.copyWith(text: text) : p]);
DiaryBook deletePost(String id) => copyWith(
posts: posts.where((p) => p.id != id).toList(),
comments: comments.where((c) => c.postId != id).toList(),
);
// ── 댓글 ───────────────────────────────────────────────
DiaryBook addComment(DiaryComment c) => copyWith(comments: [...comments, c]);
DiaryBook updateComment(String id, String text) =>
copyWith(comments: [for (final c in comments) c.id == id ? c.copyWith(text: text) : c]);
DiaryBook deleteComment(String id) => copyWith(comments: comments.where((c) => c.id != id).toList());
}
@@ -0,0 +1,21 @@
import 'dart:ui' show Color;
/// 기록할 때 고르는 오늘의 기분 (시안 MOODS)
enum Mood {
happy('행복', Color(0xFFFFB04D)),
excited('신남', Color(0xFFFF7AA0)),
calm('편안', Color(0xFF3FB58C)),
meh('그냥', Color(0xFF9AA0A6)),
sad('시무룩', Color(0xFF5B9BD0)),
sick('아픔', Color(0xFFFF6B6B));
const Mood(this.label, this.tint);
final String label;
final Color tint;
/// 표정 그림
String get asset => 'assets/svg/mood/mood_$name.svg';
static Mood? byName(String? name) => Mood.values.where((m) => m.name == name).firstOrNull;
}
@@ -0,0 +1,179 @@
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/theme/app_typography.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/diary/presentation/widgets/diary_cover.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart';
import 'package:tma_app/shared/widgets/rup_button.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 String _kDefaultPostText = '오늘의 기록 🐾';
const double _kPhotoHeight = 220;
const double _kTextMinHeight = 120;
/// 기록하기 — 사진 · 오늘 기분 · 글
class DiaryComposePage extends ConsumerStatefulWidget {
const DiaryComposePage({super.key, required this.diaryId});
final String diaryId;
@override
ConsumerState<DiaryComposePage> createState() => _DiaryComposePageState();
}
class _DiaryComposePageState extends ConsumerState<DiaryComposePage> {
final _text = TextEditingController();
Mood _mood = Mood.happy;
String? _photo;
bool _saving = false;
@override
void dispose() {
_text.dispose();
super.dispose();
}
Future<void> _pickPhoto() async {
final path = await pickAndStorePhoto(ref.read(petPhotoStoreProvider));
if (path == null || !mounted) return;
setState(() => _photo = path);
}
Future<void> _submit() async {
if (_saving) return;
final toast = ref.read(toastProvider.notifier);
// 하루 한 번 — 다른 경로로 들어와도 두 번 쓰지 않게
if (ref.read(diaryBookProvider).wroteOn(widget.diaryId, DateTime.now())) {
toast.show('오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요');
context.pop();
return;
}
setState(() => _saving = true);
try {
final text = _text.text.trim();
await ref.read(diaryProvider.notifier).addPost(
diaryId: widget.diaryId,
text: text.isEmpty ? _kDefaultPostText : text,
mood: _mood,
photoPath: _photo,
);
toast.show('기록을 남겼어요');
if (mounted) context.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final diary = ref.watch(diaryBookProvider).diary(widget.diaryId);
if (diary == null) return const Scaffold(body: SizedBox.shrink());
final label = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.ink2,
height: 1.2,
);
return Scaffold(
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SubPageHeader(title: '${diary.name} 기록하기', displayFont: true, onBack: () => context.pop()),
PetPhotoSlot(photoPath: _photo, onPick: () => unawaited(_pickPhoto()), height: _kPhotoHeight, radius: 12),
const SizedBox(height: 26),
Text('오늘 기분은 어땠나요?', style: label),
const SizedBox(height: AppSpacing.md),
Row(
children: [
for (final (i, m) in Mood.values.indexed) ...[
if (i > 0) const SizedBox(width: 7),
Expanded(child: _MoodTile(mood: m, selected: m == _mood, onTap: () => setState(() => _mood = m))),
],
],
),
const SizedBox(height: AppSpacing.lg),
Text('오늘 하루는 어떠셨나요?', style: label),
const SizedBox(height: AppSpacing.md),
RupTextField(
controller: _text,
multiline: true,
minHeight: _kTextMinHeight,
hintText: '오늘 우리 아이의 이야기를 적어주세요…',
),
// 시안: 칸 아래 16 + 버튼 위 4 + 입력칸 아래 글자 여백
const SizedBox(height: AppSpacing.xxl),
RupButton(label: '기록 남기기', large: true, onPressed: () => unawaited(_submit())),
],
),
),
),
);
}
}
class _MoodTile extends StatelessWidget {
const _MoodTile({required this.mood, required this.selected, required this.onTap});
final Mood mood;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: mood.label,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? mood.tint.withValues(alpha: .16) : AppColors.surface2,
borderRadius: BorderRadius.circular(AppSpacing.md),
border: Border.all(color: selected ? mood.tint : Colors.transparent, width: 1.5),
),
child: Column(
children: [
MoodIcon(mood: mood, size: 26),
const SizedBox(height: 5),
Text(
mood.label,
maxLines: 1,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 10.5,
fontWeight: FontWeight.w700,
height: AppTypography.normalHeight,
color: selected ? AppColors.ink : AppColors.muted,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,603 @@
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/diary/domain/diary.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/diary/presentation/widgets/diary_post_card.dart';
import 'package:tma_app/features/diary/presentation/widgets/post_actions.dart';
import 'package:tma_app/features/schedule/presentation/widgets/schedule_calendar.dart' show kWeekdayHeads;
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_card.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kNormal = AppTypography.normalHeight;
/// 쓰기 버튼 (시안: 오른쪽 20, 화면 아래에서 90 = 홈 인디케이터 34 + 56)
const double _kFabSize = 52;
const double _kFabGap = 56;
/// 오늘 이미 쓴 뒤의 쓰기 버튼 색 (시안 #D9D2C6 / rgba(120,110,95,.72))
const Color _kFabDoneBg = Color(0xFFD9D2C6);
const Color _kFabDoneIcon = Color(0xB8786E5F);
/// 아이콘 전환 튕김 (.34s cubic-bezier(.34,1.56,.64,1))
const Duration _kPop = Duration(milliseconds: 340);
const Cubic _kPopCurve = Cubic(.34, 1.56, .64, 1);
/// 피드 보기 방식 — 순서대로 돌아간다
enum FeedMode {
all('모두 보기', RupIconName.diary),
image('사진만 보기', RupIconName.camera),
text('글만 보기', RupIconName.text);
const FeedMode(this.title, this.icon);
final String title;
final RupIconName icon;
FeedMode get next => FeedMode.values[(index + 1) % FeedMode.values.length];
}
/// 다이어리 안 — 기록 피드(모두·사진·글) / 달력 보기, 검색, 오늘 쓰기
class DiaryPage extends ConsumerStatefulWidget {
const DiaryPage({super.key, required this.diaryId});
final String diaryId;
@override
ConsumerState<DiaryPage> createState() => _DiaryPageState();
}
class _DiaryPageState extends ConsumerState<DiaryPage> {
final _query = TextEditingController();
bool _calendar = false;
FeedMode _mode = FeedMode.all;
late DateTime _calSel = dateOnly(DateTime.now());
late DateTime _calMonth = DateTime(_calSel.year, _calSel.month);
@override
void initState() {
super.initState();
_query.addListener(() => setState(() {}));
}
@override
void dispose() {
_query.dispose();
super.dispose();
}
void _write(bool wroteToday) {
if (wroteToday) {
ref.read(toastProvider.notifier).show('오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요');
return;
}
unawaited(context.push(AppRoutes.diaryWrite(widget.diaryId)));
}
@override
Widget build(BuildContext context) {
final book = ref.watch(diaryBookProvider);
final diary = book.diary(widget.diaryId);
// 지워진 다이어리 — 설정에서 지운 직후 등
if (diary == null) return const Scaffold(body: SizedBox.shrink());
final today = dateOnly(DateTime.now());
final posts = book.postsOf(diary.id);
final q = _query.text.trim().toLowerCase();
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;
return Scaffold(
body: Stack(
children: [
SafeArea(
bottom: false,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.xs, AppSpacing.xl, safeBottom + _kFabGap + _kFabSize),
children: [
SubPageHeader(
title: diary.name,
subtitle: '기록 ${posts.length}개',
displayFont: true,
onBack: () => context.pop(),
trailing: _HeaderIconButton(
label: '다이어리 설정',
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
),
),
Row(
children: [
Expanded(child: _SearchField(controller: _query)),
const SizedBox(width: AppSpacing.sm),
_ToggleIconButton(
label: '보기 전환',
icon: _calendar ? RupIconName.diary : RupIconName.calendar,
onTap: () => setState(() => _calendar = !_calendar),
),
if (!_calendar) ...[
const SizedBox(width: AppSpacing.sm),
_ToggleIconButton(
label: _mode.title,
icon: _mode.icon,
onTap: () => setState(() => _mode = _mode.next),
),
],
],
),
const SizedBox(height: 14),
if (_calendar)
..._calendarBody(diary, filtered, today)
else
..._feedBody(filtered, searching: q.isNotEmpty, wroteToday: wroteToday),
],
),
),
Positioned(
right: AppSpacing.xl,
bottom: safeBottom + _kFabGap,
child: _WriteFab(done: wroteToday, onTap: () => _write(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),
),
const SizedBox(height: AppSpacing.md),
if (posts.isEmpty)
RupCard(
child: searching
? const EmptyMessage('검색 결과가 없어요', padding: 40)
: _NoPosts(onWrite: wroteToday ? null : () => _write(false)),
)
else if (_mode == FeedMode.image && shown.isEmpty)
const RupCard(child: EmptyMessage('사진이 있는 기록이 없어요', padding: 32))
else if (_mode == FeedMode.image)
LayoutBuilder(
builder: (context, c) {
final w = (c.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [for (final p in shown) SizedBox(width: w, child: menu(p))],
);
},
)
else
for (final (i, p) in shown.indexed) ...[
if (_mode == FeedMode.text && i > 0) const SizedBox(height: AppSpacing.md),
menu(p),
],
];
}
List<Widget> _calendarBody(Diary diary, List<DiaryPost> filtered, DateTime today) {
final book = ref.read(diaryBookProvider);
final all = book.postsOf(diary.id);
final onDay = filtered.where((p) => p.date == _calSel).toList();
return [
_DiaryCalendar(
month: _calMonth,
selected: _calSel,
today: today,
posts: all,
onMonth: (m) => setState(() => _calMonth = m),
onSelect: (d) {
if (d.isAfter(today)) {
ref.read(toastProvider.notifier).show('미래의 다이어리는 쓸 수 없어요', icon: RupIconName.alert);
} else {
setState(() => _calSel = d);
}
},
),
const SizedBox(height: AppSpacing.lg),
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, AppSpacing.md),
child: Text(
'${formatMonthDay(_calSel)} · 기록 ${onDay.length}개',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
),
if (onDay.isEmpty)
const RupCard(child: EmptyMessage('이 날의 기록이 없어요', padding: 30))
else
for (final p in onDay) PolaroidPost(post: p, onMenu: () => unawaited(managePost(context, ref, p))),
];
}
}
/// 보기 방식에 맞는 기록 모양을 고르고 ⋮ 메뉴를 붙인다
class _PostView extends ConsumerWidget {
const _PostView({required this.post, required this.mode});
final DiaryPost post;
final FeedMode mode;
@override
Widget build(BuildContext context, WidgetRef ref) {
void menu() => unawaited(managePost(context, ref, post));
return switch (mode) {
FeedMode.all => PolaroidPost(post: post, onMenu: menu),
FeedMode.image => PhotoPostTile(post: post, onMenu: menu),
FeedMode.text => TextPostCard(post: post, onMenu: menu),
};
}
}
class _NoPosts extends StatelessWidget {
const _NoPosts({required this.onWrite});
/// 오늘 아직 안 썼으면 "첫 기록 남기기"
final VoidCallback? onWrite;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: 40),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: AppSpacing.lg, bottom: AppSpacing.xl),
child: Text('아직 기록이 없어요.', style: style),
),
if (onWrite != null)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
child: Semantics(
button: true,
child: GestureDetector(
onTap: onWrite,
child: Text('+ 첫 기록 남기기', style: style.copyWith(color: AppColors.primary600, fontWeight: FontWeight.w700)),
),
),
),
],
),
);
}
}
class _HeaderIconButton extends StatelessWidget {
const _HeaderIconButton({required this.label, required this.onTap, required this.child});
final String label;
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) {
return Pressable(
onTap: onTap,
pressedScale: .9,
semanticLabel: label,
child: SizedBox(width: 30, height: 34, child: Center(child: child)),
);
}
}
/// 검색칸 — 돋보기 + 지우기
class _SearchField extends StatelessWidget {
const _SearchField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14, height: 1.2);
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 13),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: Row(
children: [
const RupIcon(RupIconName.search, size: 18, stroke: 2, color: AppColors.muted),
const SizedBox(width: 9),
Expanded(
child: TextField(
controller: controller,
style: style,
cursorColor: AppColors.primary,
textInputAction: TextInputAction.search,
decoration: InputDecoration.collapsed(hintText: '다이어리 검색', hintStyle: style.copyWith(color: AppColors.muted)),
),
),
if (controller.text.isNotEmpty)
Semantics(
button: true,
label: '검색어 지우기',
excludeSemantics: true,
child: GestureDetector(
onTap: controller.clear,
child: const RupIcon(RupIconName.x, size: 16, stroke: 2.2, color: AppColors.muted),
),
),
],
),
);
}
}
/// 네모 아이콘 버튼 — 누르면 아이콘이 튕기며 바뀐다
class _ToggleIconButton extends StatelessWidget {
const _ToggleIconButton({required this.label, required this.icon, required this.onTap});
final String label;
final RupIconName icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Pressable(
onTap: onTap,
pressedScale: .9,
semanticLabel: label,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.line, width: 1.5),
),
alignment: Alignment.center,
child: AnimatedSwitcher(
duration: _kPop,
switchInCurve: _kPopCurve,
transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child),
child: RupIcon(icon, key: ValueKey(icon), size: 21, stroke: 2, color: AppColors.primary600),
),
),
);
}
}
/// 오른쪽 아래 쓰기 버튼 — 오늘 쓴 뒤에는 회색 ✓
class _WriteFab extends StatelessWidget {
const _WriteFab({required this.done, required this.onTap});
final bool done;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Pressable(
onTap: onTap,
pressedScale: .92,
semanticLabel: done ? '오늘 작성 완료' : '다이어리 쓰기',
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _kFabSize,
height: _kFabSize,
decoration: BoxDecoration(
color: done ? _kFabDoneBg : AppColors.primary,
shape: BoxShape.circle,
boxShadow: [
done
? const BoxShadow(color: Color(0x38786C5A), blurRadius: 8, offset: Offset(0, 2))
: BoxShadow(color: AppColors.primary.withValues(alpha: .38), blurRadius: 14, offset: const Offset(0, 4)),
],
),
alignment: Alignment.center,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: ScaleTransition(scale: Tween(begin: .55, end: 1.0).animate(anim), child: child),
),
child: done
? const RupIcon(RupIconName.check, key: ValueKey('done'), size: 23, stroke: 2.6, color: _kFabDoneIcon)
: const RupIcon(RupIconName.edit, key: ValueKey('write'), size: 23, stroke: 2.4, color: Colors.white),
),
),
);
}
}
/// 다이어리 달력 — 날마다 그날 첫 기록의 기분 표정
class _DiaryCalendar extends StatelessWidget {
const _DiaryCalendar({
required this.month,
required this.selected,
required this.today,
required this.posts,
required this.onMonth,
required this.onSelect,
});
final DateTime month;
final DateTime selected;
final DateTime today;
final List<DiaryPost> posts;
final ValueChanged<DateTime> onMonth;
final ValueChanged<DateTime> onSelect;
@override
Widget build(BuildContext context) {
final lead = month.weekday % 7;
final days = daysInMonth(month.year, month.month);
final cells = <int?>[for (var i = 0; i < lead; i++) null, for (var d = 1; d <= days; d++) d];
while (cells.length % 7 != 0) {
cells.add(null);
}
// 날짜별 기록 — 새 기록이 앞에 있으므로 그날 마지막(가장 최근) 기분이 나온다
final byDay = <DateTime, DiaryPost>{};
for (final p in posts.reversed) {
byDay.putIfAbsent(p.date, () => p);
}
final textTheme = Theme.of(context).textTheme;
Widget nav(RupIconName icon, String label, int delta) => Pressable(
onTap: () => onMonth(DateTime(month.year, month.month + delta)),
pressedScale: .9,
semanticLabel: label,
child: SizedBox.square(
dimension: 34,
child: Center(child: RupIcon(icon, size: 20, stroke: 2.4, color: AppColors.ink2)),
),
);
return RupCard(
padding: const EdgeInsets.all(18),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'${month.year}년 ${month.month}월',
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 20,
fontWeight: FontWeight.w700,
height: AppTypography.displayNormalHeight,
color: AppColors.ink,
),
),
),
nav(RupIconName.chevL, '이전 달', -1),
const SizedBox(width: AppSpacing.xs),
nav(RupIconName.chevR, '다음 달', 1),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
for (final (i, d) in kWeekdayHeads.indexed) ...[
if (i > 0) const SizedBox(width: 2),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Text(
d,
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.muted,
height: _kNormal,
),
),
),
),
],
],
),
for (var w = 0; w < cells.length; w += 7) ...[
if (w > 0) const SizedBox(height: 2),
Row(
children: [
for (var i = 0; i < 7; i++) ...[
if (i > 0) const SizedBox(width: 2),
Expanded(
child: cells[w + i] == null
? const SizedBox.shrink()
: _MoodCell(
date: DateTime(month.year, month.month, cells[w + i]!),
post: byDay[DateTime(month.year, month.month, cells[w + i]!)],
selected: selected,
today: today,
onTap: onSelect,
),
),
],
],
),
],
],
),
);
}
}
class _MoodCell extends StatelessWidget {
const _MoodCell({
required this.date,
required this.post,
required this.selected,
required this.today,
required this.onTap,
});
final DateTime date;
final DiaryPost? post;
final DateTime selected;
final DateTime today;
final ValueChanged<DateTime> onTap;
@override
Widget build(BuildContext context) {
final isSel = date == selected;
final isToday = date == today;
final future = date.isAfter(today);
final mood = post?.mood;
return Semantics(
button: true,
selected: isSel,
label: '${date.month}월 ${date.day}일${mood == null ? '' : ', ${mood.label}'}',
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(date),
child: Opacity(
opacity: future ? .4 : 1,
child: AspectRatio(
aspectRatio: 1 / 1.05,
child: Container(
padding: const EdgeInsets.only(top: 6),
decoration: BoxDecoration(
color: isSel ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: Column(
children: [
Text(
'${date.day}',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
height: _kNormal,
color: isSel ? Colors.white : (isToday ? AppColors.primary600 : AppColors.ink),
),
),
const SizedBox(height: 3),
SizedBox(height: 18, child: mood == null ? null : Center(child: MoodIcon(mood: mood, size: 17))),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,191 @@
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/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart';
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
import 'package:tma_app/shared/widgets/heart_toggle.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_card.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/seg_toggle.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kNormal = AppTypography.normalHeight;
/// 알림 줄 — 아이콘·색·제목·설명 (시안 NOTIF_ROWS)
typedef _NoticeRow = ({DiaryNotice notice, RupIconName icon, Color tint, String title, String desc});
const List<_NoticeRow> _kNoticeRows = [
(notice: DiaryNotice.newPost, icon: RupIconName.diary, tint: AppColors.primary, title: '새 글 알림', desc: '다이어리에 새 기록이 올라오면 알려드려요.'),
(notice: DiaryNotice.newComment, icon: RupIconName.comment, tint: AppColors.catVaccine, title: '내 글에 새 댓글 알림', desc: '내가 남긴 기록에 댓글이 달리면 알려드려요.'),
(notice: DiaryNotice.reminder, icon: RupIconName.paw, tint: AppColors.accent, title: '우리 아이 기록 알림', desc: '오늘 일기를 쓰지 않았을 때 잊지 않도록 알려드려요.'),
];
/// 다이어리 설정 — 정보 수정 · 개인/공유 · 알림 · 제거
class DiarySettingsPage extends ConsumerWidget {
const DiarySettingsPage({super.key, required this.diaryId});
final String diaryId;
Future<void> _delete(BuildContext context, WidgetRef ref, Diary diary) async {
final ok = await showRupConfirm(
context,
title: '다이어리를 제거할까요?',
message: '다이어리를 삭제하면 다시 복구할 수 없으며,\n이때까지 적은 기록이 전부 삭제돼요.',
confirmLabel: '제거하기',
destructive: true,
layout: RupConfirmLayout.row,
);
if (!ok || !context.mounted) return;
// 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
context.go(AppRoutes.records);
await ref.read(diaryProvider.notifier).deleteDiary(diary.id);
ref.read(toastProvider.notifier).show('다이어리를 제거했어요');
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final diary = ref.watch(diaryBookProvider).diary(diaryId);
if (diary == null) return const Scaffold(body: SizedBox.shrink());
final textTheme = Theme.of(context).textTheme;
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: diary.name,
displayFont: true,
onBack: () => context.pop(),
trailing: Pressable(
onTap: () => unawaited(showDiaryFormSheet(context, editing: diary)),
pressedScale: .9,
semanticLabel: '다이어리 수정하기',
child: const SizedBox(
width: 30,
height: 34,
child: Center(child: RupIcon(RupIconName.pencilCute, size: 25, stroke: 1.9, color: AppColors.ink2)),
),
),
),
const SizedBox(height: 2),
SegToggle<bool>(
large: true,
first: (false, '개인 다이어리'),
second: (true, '공유 다이어리'),
value: false,
iconOf: (shared, on) => RupIcon(shared ? RupIconName.users : RupIconName.user, size: 18, stroke: on ? 2.4 : 1.9),
onChanged: (shared) {
if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
},
),
const SizedBox(height: 26),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '알림 설정'),
),
const SizedBox(height: AppSpacing.md),
RupCard(
child: Column(
children: [
for (final (i, row) in _kNoticeRows.indexed)
Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
decoration: BoxDecoration(
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: row.tint.withValues(alpha: .14),
borderRadius: BorderRadius.circular(11),
),
alignment: Alignment.center,
child: RupIcon(row.icon, size: 19, stroke: 2, color: row.tint),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.title,
style: textTheme.titleSmall!.copyWith(fontSize: 14.5, height: _kNormal),
),
const SizedBox(height: 2),
Text(
row.desc,
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.45),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
HeartToggle(
value: diary.notifies(row.notice),
label: row.title,
onChanged: (on) => unawaited(ref.read(diaryProvider.notifier).updateDiary(
diary.copyWith(
mutedNotices: on
? ({...diary.mutedNotices}..remove(row.notice))
: {...diary.mutedNotices, row.notice},
),
)),
),
],
),
),
],
),
),
const SizedBox(height: 44),
Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => unawaited(_delete(context, ref, diary)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: Text(
'다이어리 제거',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,84 @@
import 'dart:math';
import 'dart:ui' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/utils/date_format.dart';
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';
/// 다이어리 · 기록 · 댓글
class DiaryNotifier extends AsyncNotifier<DiaryBook> {
DiaryRepository get _repo => ref.read(diaryRepositoryProvider);
int _serial = 0;
@override
Future<DiaryBook> build() => ref.watch(diaryRepositoryProvider).fetch();
DiaryBook get _book => state.value ?? DiaryBook.empty;
String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}';
Future<void> _commit(DiaryBook next) async {
await _repo.save(next);
if (ref.mounted) state = AsyncData(next);
}
/// 새 다이어리 — 가득 찼으면 null
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
String? coverPath,
}) async {
if (_book.atMax) return null;
final diary = Diary(
id: _newId('d'),
name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(),
targetIds: targetIds,
ribbon: ribbon,
tint: kDiaryTints[Random().nextInt(kDiaryTints.length)],
coverPath: coverPath,
);
await _commit(_book.addDiary(diary));
return diary;
}
Future<void> updateDiary(Diary diary) => _commit(_book.updateDiary(diary));
Future<void> deleteDiary(String id) => _commit(_book.deleteDiary(id));
Future<void> addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) {
final now = DateTime.now();
return _commit(_book.addPost(DiaryPost(
id: _newId('p'),
diaryId: diaryId,
date: dateOnly(now),
createdAt: now,
text: text,
mood: mood,
photoPath: photoPath,
)));
}
Future<void> updatePostText(String id, String text) => _commit(_book.updatePostText(id, text));
Future<void> deletePost(String id) => _commit(_book.deletePost(id));
Future<void> addComment(String postId, String text) => _commit(_book.addComment(
DiaryComment(id: _newId('c'), postId: postId, text: text, createdAt: DateTime.now()),
));
Future<void> updateComment(String id, String text) => _commit(_book.updateComment(id, text));
Future<void> deleteComment(String id) => _commit(_book.deleteComment(id));
/// 아이를 지우면 다이어리 대상에서만 뗀다
Future<void> dropTarget(String targetId) => _commit(_book.dropTarget(targetId));
}
final diaryProvider = AsyncNotifierProvider<DiaryNotifier, DiaryBook>(DiaryNotifier.new);
/// 불러오기 전에도 쓸 수 있는 다이어리 (빈 목록으로 시작)
final diaryBookProvider = Provider<DiaryBook>((ref) => ref.watch(diaryProvider).value ?? DiaryBook.empty);
@@ -0,0 +1,291 @@
import 'dart:ui' show ImageFilter;
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_shadows.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.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/shared/widgets/dashed_border_box.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 표지 가로:세로 1:1.16 (시안 aspectRatio 1 / 1.16)
const double kDiaryCoverAspect = 1 / 1.16;
const double _kCoverRadius = 14;
/// 밝은 리본 위 글리프 색 (시안 #3A352F)
const Color _kRibbonInk = Color(0xFF3A352F);
/// 밝은 색인지 — 리본·체크 표시를 어두운 색으로 그릴지 (시안 ribIsLight)
bool isLightColor(Color c) => (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) * 255 > 165;
/// 기분 표정 그림
class MoodIcon extends StatelessWidget {
const MoodIcon({super.key, required this.mood, required this.size});
final Mood mood;
final double size;
@override
Widget build(BuildContext context) {
return SvgPicture.asset(mood.asset, width: size, height: size, semanticsLabel: mood.label);
}
}
/// 다이어리 표지 — 사진(없으면 고유색), 아이들, 책갈피 리본, 이름·기록 수
class DiaryCoverTile extends StatelessWidget {
const DiaryCoverTile({super.key, required this.diary, required this.postCount, required this.onTap});
final Diary diary;
final int postCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cover = diary.coverPath;
const shadow = [Shadow(color: Color(0x59000000), blurRadius: 8, offset: Offset(0, 1))];
return Semantics(
container: true,
button: true,
label: '${diary.name}, 기록 $postCount개',
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: kDiaryCoverAspect,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kCoverRadius),
boxShadow: AppShadows.sm,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_kCoverRadius),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: diary.tint),
if (cover != null) PetPhotoImage(path: cover),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [.36, 1],
colors: [Color(0x0A000000), Color(0xAD000000)],
),
),
),
Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon)),
Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)),
Positioned(
left: 14,
right: 12,
bottom: 13,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
diary.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 17,
fontWeight: FontWeight.w800,
height: 1.2,
color: Colors.white,
shadows: shadow,
),
),
const SizedBox(height: 5),
Text(
'기록 $postCount개',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
height: AppTypography.normalHeight,
color: Colors.white.withValues(alpha: .95),
shadows: shadow,
),
),
],
),
),
],
),
),
),
),
),
);
}
}
/// 표지 왼쪽 위 아이들 — 조금씩 겹쳐 놓는다
class _CoverTargets extends StatelessWidget {
const _CoverTargets({required this.ids});
final List<String> ids;
static const double _size = 28;
static const double _overlap = 8;
@override
Widget build(BuildContext context) {
if (ids.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: _size,
width: _size + (ids.length - 1) * (_size - _overlap),
child: Stack(
clipBehavior: Clip.none,
children: [
for (final (i, id) in ids.indexed)
Positioned(
left: i * (_size - _overlap),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.white.withValues(alpha: .9), spreadRadius: 2)],
),
child: TargetAvatar(targetId: id, size: _size),
),
),
],
),
);
}
}
/// 책갈피 리본 — 반투명 유리 느낌(뒤를 흐리게) + 고른 색
class DiaryRibbon extends StatelessWidget {
const DiaryRibbon({super.key, required this.color, this.shared = false});
final Color color;
final bool shared;
static const double width = 35;
static const double height = 58;
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: const _RibbonClipper(),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 7, sigmaY: 7),
child: Container(
width: width,
height: height,
color: color.withValues(alpha: .6),
padding: const EdgeInsets.only(top: 11),
alignment: Alignment.topCenter,
child: RupGlyphIcon(
shared ? RupGlyph.diaryShared : RupGlyph.diaryPrivate,
size: 19,
color: isLightColor(color) ? _kRibbonInk : Colors.white,
),
),
),
);
}
}
/// 시안 리본 모양 (35×58) — 아래가 V 자로 파인 끝
class _RibbonClipper extends CustomClipper<Path> {
const _RibbonClipper();
@override
Path getClip(Size size) {
final sx = size.width / DiaryRibbon.width;
final sy = size.height / DiaryRibbon.height;
return Path()
..moveTo(0, 0)
..lineTo(35 * sx, 0)
..lineTo(35 * sx, 53 * sy)
..quadraticBezierTo(35 * sx, 57.5 * sy, 31.2 * sx, 55.6 * sy)
..lineTo(20 * sx, 47.6 * sy)
..quadraticBezierTo(17.5 * sx, 45.9 * sy, 15 * sx, 47.6 * sy)
..lineTo(3.8 * sx, 55.6 * sy)
..quadraticBezierTo(0, 57.5 * sy, 0, 53 * sy)
..close();
}
@override
bool shouldReclip(_RibbonClipper oldClipper) => false;
}
/// "새 다이어리" 칸 — 점선 테두리
class AddDiaryTile extends StatelessWidget {
const AddDiaryTile({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
label: '새 다이어리',
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: kDiaryCoverAspect,
child: DashedBorderBox(
color: AppColors.line,
radius: _kCoverRadius,
strokeWidth: 2,
backgroundColor: AppColors.surface,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.plus, size: 26, color: AppColors.muted),
const SizedBox(height: 6),
Text(
'새 다이어리',
style: textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
const SizedBox(height: 6),
Text(
'최대 $kMaxDiaries개',
style: textTheme.bodySmall!.copyWith(fontSize: 11, color: AppColors.muted, height: AppTypography.normalHeight),
),
],
),
),
),
),
);
}
}
/// 표지 그리드 두 칸 — 간격 12
class DiaryGrid extends StatelessWidget {
const DiaryGrid({super.key, required this.children});
final List<Widget> children;
static const double gap = AppSpacing.md;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final w = (constraints.maxWidth - gap) / 2;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [for (final c in children) SizedBox(width: w, child: c)],
);
},
);
}
}
@@ -0,0 +1,520 @@
import 'dart:async';
import 'dart:math' as math;
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/core/utils/date_format.dart';
import 'package:tma_app/features/auth/presentation/providers/user_provider.dart';
import 'package:tma_app/features/diary/domain/diary.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/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.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/rup_dialog.dart';
const double _kNormal = AppTypography.normalHeight;
/// 폴라로이드 그림자 (시안 0 12 26 rgba(70,45,20,.15), 0 2 6 rgba(70,45,20,.10))
const List<BoxShadow> _kPolaroidShadow = [
BoxShadow(color: Color(0x26462D14), blurRadius: 26, offset: Offset(0, 12)),
BoxShadow(color: Color(0x1A462D14), blurRadius: 6, offset: Offset(0, 2)),
];
/// 사진 위 마스킹테이프
const Color _kTapeTop = Color(0x6BFFCE80);
const Color _kTapeBottom = Color(0x4DFFBE6E);
TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color, {double height = _kNormal}) =>
Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: height);
TextStyle _display(double size, {FontWeight weight = FontWeight.w700, Color color = AppColors.ink}) =>
TextStyle(fontFamily: AppFonts.display, fontSize: size, fontWeight: weight, color: color, height: AppTypography.displayNormalHeight);
/// 글쓴이 아바타 — 지금은 나 하나
class _AuthorAvatar extends ConsumerWidget {
const _AuthorAvatar({required this.size});
final double size;
@override
Widget build(BuildContext context, WidgetRef ref) =>
PersonAvatar(size: size, photoPath: ref.watch(profileProvider).photoPath);
}
/// 글쓴이 · 날짜 · 기분 · ⋮
class _PostMeta extends ConsumerWidget {
const _PostMeta({required this.post, required this.onMenu, this.avatar = 28, this.nameSize = 13});
final DiaryPost post;
final VoidCallback onMenu;
final double avatar;
final double nameSize;
@override
Widget build(BuildContext context, WidgetRef ref) {
final mood = post.mood;
return Row(
children: [
_AuthorAvatar(size: avatar),
SizedBox(width: avatar > 30 ? 10 : 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ref.watch(userNicknameProvider), style: _display(nameSize)),
const SizedBox(height: 1),
Text(relativeDayLabel(post.date, DateTime.now()), style: _ui(context, 11, FontWeight.w400, AppColors.muted)),
],
),
),
if (mood != null) ...[MoodIcon(mood: mood, size: 22), const SizedBox(width: 9)],
_MoreButton(onTap: onMenu),
],
);
}
}
class _MoreButton extends StatelessWidget {
const _MoreButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: '기록 관리',
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: const Padding(
padding: EdgeInsets.all(2),
child: RupIcon(RupIconName.moreV, size: 19, color: AppColors.muted),
),
),
);
}
}
/// 폴라로이드 기록 — 사진(있을 때만, 위에 마스킹테이프) · 글 · 댓글
class PolaroidPost extends StatefulWidget {
const PolaroidPost({super.key, required this.post, required this.onMenu});
final DiaryPost post;
final VoidCallback onMenu;
@override
State<PolaroidPost> createState() => _PolaroidPostState();
}
class _PolaroidPostState extends State<PolaroidPost> {
bool _open = false;
@override
Widget build(BuildContext context) {
final post = widget.post;
final photo = post.photoPath;
return Padding(
padding: const EdgeInsets.fromLTRB(2, 4, 2, 28),
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
padding: EdgeInsets.fromLTRB(12, photo == null ? 14 : 12, 12, 0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(3),
boxShadow: _kPolaroidShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (photo != null)
AspectRatio(
aspectRatio: 1,
child: ColoredBox(color: AppColors.primary50, child: PetPhotoImage(path: photo)),
),
Padding(
padding: EdgeInsets.fromLTRB(6, photo == null ? 2 : 16, 6, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PostMeta(post: post, onMenu: widget.onMenu),
const SizedBox(height: 20),
Text(post.text, style: _ui(context, 15, FontWeight.w400, AppColors.ink, height: 1.6)),
const SizedBox(height: AppSpacing.sm),
],
),
),
_CommentToggle(postId: post.id, open: _open, onTap: () => setState(() => _open = !_open)),
if (_open) _Comments(postId: post.id),
],
),
),
if (photo != null)
Positioned(
top: -9,
left: 0,
right: 0,
child: Center(
child: Transform.rotate(angle: -2.4 * math.pi / 180, child: const _Tape()),
),
),
],
),
);
}
}
class _Tape extends StatelessWidget {
const _Tape();
@override
Widget build(BuildContext context) {
return Container(
width: 104,
height: 26,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [_kTapeTop, _kTapeBottom],
),
boxShadow: [BoxShadow(color: Color(0x12000000), blurRadius: 3, offset: Offset(0, 1))],
),
);
}
}
/// "댓글 N" — 누르면 댓글을 펼친다
class _CommentToggle extends ConsumerWidget {
const _CommentToggle({required this.postId, required this.open, required this.onTap});
final String postId;
final bool open;
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(diaryBookProvider.select((b) => b.commentsOf(postId).length));
final color = open ? AppColors.primary : AppColors.ink2;
return Container(
margin: const EdgeInsets.only(top: AppSpacing.sm),
padding: const EdgeInsets.fromLTRB(6, 11, 6, 13),
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))),
child: Row(
children: [
Semantics(
button: true,
expanded: open,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Row(
children: [
RupIcon(RupIconName.comment, size: 19, color: color),
const SizedBox(width: 6),
Text(count > 0 ? '댓글 $count' : '댓글', style: _ui(context, 13, FontWeight.w600, color)),
],
),
),
),
],
),
);
}
}
/// 댓글 목록 + 새 댓글 — 내 댓글은 고치거나 지울 수 있다
class _Comments extends ConsumerStatefulWidget {
const _Comments({required this.postId});
final String postId;
@override
ConsumerState<_Comments> createState() => _CommentsState();
}
class _CommentsState extends ConsumerState<_Comments> {
final _draft = TextEditingController();
final _edit = TextEditingController();
String? _editingId;
@override
void initState() {
super.initState();
_draft.addListener(() => setState(() {}));
}
@override
void dispose() {
_draft.dispose();
_edit.dispose();
super.dispose();
}
Future<void> _add() async {
final text = _draft.text.trim();
if (text.isEmpty) return;
_draft.clear();
await ref.read(diaryProvider.notifier).addComment(widget.postId, text);
}
Future<void> _saveEdit() async {
final id = _editingId;
final text = _edit.text.trim();
setState(() => _editingId = null);
if (id == null || text.isEmpty) return;
await ref.read(diaryProvider.notifier).updateComment(id, text);
}
Future<void> _delete(DiaryComment c) async {
final ok = await showRupConfirm(
context,
title: '댓글을 삭제하시겠어요?',
message: '삭제한 댓글은 다시 볼 수 없어요.',
confirmLabel: '삭제하기',
layout: RupConfirmLayout.row,
);
if (ok) await ref.read(diaryProvider.notifier).deleteComment(c.id);
}
@override
Widget build(BuildContext context) {
final comments = ref.watch(diaryBookProvider.select((b) => b.commentsOf(widget.postId)));
final name = ref.watch(userNicknameProvider);
final muted = _ui(context, 12, FontWeight.w600, AppColors.muted);
return Container(
padding: const EdgeInsets.fromLTRB(15, 12, 15, 14),
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (comments.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 6, bottom: AppSpacing.md),
child: Text('첫 댓글을 남겨보세요', textAlign: TextAlign.center, style: _ui(context, 13, FontWeight.w400, AppColors.muted)),
),
for (final c in comments)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _AuthorAvatar(size: 30),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text(name, style: _display(12.5))),
if (_editingId != c.id) ...[
_SmallAction(label: '수정', style: muted, onTap: () => setState(() {
_editingId = c.id;
_edit.text = c.text;
})),
const SizedBox(width: 10),
_SmallAction(label: '삭제', style: muted, onTap: () => unawaited(_delete(c))),
],
],
),
const SizedBox(height: 5),
if (_editingId == c.id)
Row(
children: [
Expanded(child: _CommentInput(controller: _edit, autofocus: true, onSubmit: _saveEdit)),
const SizedBox(width: 6),
_SoftIconButton(
label: '수정 저장',
onTap: () => unawaited(_saveEdit()),
child: const RupIcon(RupIconName.check, size: 16, stroke: 2.4, color: AppColors.primary600),
),
],
)
else
Text(c.text, style: _ui(context, 13.5, FontWeight.w400, AppColors.ink, height: 1.45)),
],
),
),
],
),
),
Row(
children: [
const _AuthorAvatar(size: 30),
const SizedBox(width: AppSpacing.sm),
Expanded(
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)),
),
),
],
),
],
),
);
}
}
class _SmallAction extends StatelessWidget {
const _SmallAction({required this.label, required this.style, required this.onTap});
final String label;
final TextStyle style;
final VoidCallback onTap;
@override
Widget build(BuildContext context) =>
Semantics(button: true, child: GestureDetector(onTap: onTap, child: Text(label, style: style)));
}
/// 연주황 작은 버튼 (.rup-btn--soft) — 등록 · ✓
class _SoftIconButton extends StatelessWidget {
const _SoftIconButton({required this.label, required this.onTap, required this.child});
final String label;
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: label,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: AppColors.primary50,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: AppColors.primary100, width: 1.5),
),
child: child,
),
),
);
}
}
/// 댓글 입력칸 — 50자까지
class _CommentInput extends StatelessWidget {
const _CommentInput({required this.controller, required this.onSubmit, this.hint, this.autofocus = false});
final TextEditingController controller;
final Future<void> Function() onSubmit;
final String? hint;
final bool autofocus;
@override
Widget build(BuildContext context) {
final style = _ui(context, 13.5, FontWeight.w400, AppColors.ink, height: 1.2);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: TextField(
controller: controller,
autofocus: 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))
.copyWith(counterText: ''),
),
);
}
}
/// 글만 보기 카드
class TextPostCard extends StatelessWidget {
const TextPostCard({super.key, required this.post, required this.onMenu});
final DiaryPost post;
final VoidCallback onMenu;
@override
Widget build(BuildContext context) {
return RupCard(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PostMeta(post: post, onMenu: onMenu, avatar: 32, nameSize: 12.5),
const SizedBox(height: AppSpacing.lg),
Text(post.text, style: _ui(context, 14, FontWeight.w400, AppColors.ink, height: 1.6)),
],
),
);
}
}
/// 사진만 보기 칸 — 오른쪽 위 ⋮
class PhotoPostTile extends StatelessWidget {
const PhotoPostTile({super.key, required this.post, required this.onMenu});
final DiaryPost post;
final VoidCallback onMenu;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: AppColors.primary50, child: PetPhotoImage(path: post.photoPath!)),
Positioned(
top: 7,
right: 7,
child: Semantics(
button: true,
label: '기록 관리',
excludeSemantics: true,
child: GestureDetector(
onTap: onMenu,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .9),
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Color(0x2E000000), blurRadius: 4, offset: Offset(0, 1))],
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.moreV, size: 17, color: AppColors.ink2),
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,491 @@
import 'dart:async';
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/diary/domain/diary.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/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_target.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart';
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/color_pick_sheet.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/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
const double _kNormal = AppTypography.normalHeight;
/// 공유 다이어리(초대·참여·멤버)는 서버가 필요해 아직 열지 않는다
const String kSharedDiarySoon = '공유 다이어리는 곧 열릴 예정이에요';
/// 표지 편집 칸 높이 (수정 시트) — 만들 때는 정사각형
const double _kEditCoverHeight = 150;
const double _kCoverRadius = 10;
TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color) =>
Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: _kNormal);
/// 시트 안의 큰 선택 카드 — 아이콘 상자 + 제목·설명 + ›
class SheetChoiceCard extends StatelessWidget {
const SheetChoiceCard({
super.key,
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
this.description,
this.titleColor = AppColors.ink,
this.showChevron = true,
this.padding = AppSpacing.lg,
this.iconBox = 46,
required this.onTap,
});
final Widget icon;
final Color iconColor;
final Color iconBackground;
final String title;
final String? description;
final Color titleColor;
final bool showChevron;
final double padding;
final double iconBox;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
button: true,
child: RupCard(
onTap: onTap,
padding: EdgeInsets.all(padding),
child: Row(
children: [
Container(
width: iconBox,
height: iconBox,
decoration: BoxDecoration(color: iconBackground, borderRadius: BorderRadius.circular(iconBox * .28)),
alignment: Alignment.center,
child: IconTheme.merge(data: IconThemeData(color: iconColor), child: icon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: _ui(context, 15, FontWeight.w700, titleColor)),
if (description != null) ...[
const SizedBox(height: 1),
Text(description!, style: _ui(context, 12, FontWeight.w400, AppColors.muted)),
],
],
),
),
if (showChevron) const RupIcon(RupIconName.chevR, size: 18, color: AppColors.muted),
],
),
),
);
}
}
/// 다이어리 추가 — 새로 만들기 / 공유 다이어리 참여하기
Future<void> showAddDiarySheet(BuildContext context, WidgetRef ref) {
return showRupSheet<void>(
context,
title: '다이어리 추가',
builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'어떻게 시작할까요?',
textAlign: TextAlign.center,
style: _ui(sheetContext, 12.5, FontWeight.w400, AppColors.muted),
),
const SizedBox(height: 18),
SheetChoiceCard(
icon: const RupGlyphIcon(RupGlyph.diaryPrivate, size: 23),
iconColor: AppColors.primary600,
iconBackground: AppColors.primary50,
title: '새 다이어리 만들기',
description: '우리 아이의 새 기록 공간을 만들어요',
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(showDiaryFormSheet(context));
},
),
const SizedBox(height: 10),
SheetChoiceCard(
icon: const RupGlyphIcon(RupGlyph.diaryShared, size: 23),
iconColor: AppColors.accent,
iconBackground: AppColors.accent100,
title: '공유 다이어리 참여하기',
description: '초대 코드로 함께 쓰는 다이어리에 참여해요',
onTap: () => ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users),
),
],
),
);
}
/// 새 다이어리 만들기 / 다이어리 수정하기 ([editing] 이 있으면 수정)
Future<void> showDiaryFormSheet(BuildContext context, {Diary? editing}) {
return showRupSheet<void>(
context,
title: editing == null ? '새 다이어리 만들기' : '다이어리 수정하기',
flushBottom: true,
builder: (context) => _DiaryForm(editing: editing),
);
}
class _DiaryForm extends ConsumerStatefulWidget {
const _DiaryForm({this.editing});
final Diary? editing;
@override
ConsumerState<_DiaryForm> createState() => _DiaryFormState();
}
class _DiaryFormState extends ConsumerState<_DiaryForm> {
late final _name = TextEditingController(text: widget.editing?.name);
late final List<String> _targets = [...?widget.editing?.targetIds];
late Color _ribbon = widget.editing?.ribbon ?? kRibbonColors.first;
late String? _cover = widget.editing?.coverPath;
bool _saving = false;
bool get _editing => widget.editing != null;
@override
void dispose() {
_name.dispose();
super.dispose();
}
Future<void> _pickCover() async {
final path = await pickAndStorePhoto(ref.read(petPhotoStoreProvider));
if (path == null || !mounted) return;
setState(() => _cover = path);
}
Future<void> _pickCustomRibbon() async {
final c = await showColorPickSheet(context, title: '리본 색상', initial: _ribbon);
if (c == null || !mounted) return;
setState(() => _ribbon = c);
}
Future<void> _submit() async {
if (_saving) return;
setState(() => _saving = true);
final notifier = ref.read(diaryProvider.notifier);
final toast = ref.read(toastProvider.notifier);
final editing = widget.editing;
final name = _name.text.trim();
try {
if (editing == null) {
final created = await notifier.createDiary(
name: name,
targetIds: List.of(_targets),
ribbon: _ribbon,
coverPath: _cover,
);
toast.show(created == null ? '다이어리는 최대 $kMaxDiaries개까지예요' : '새 다이어리를 만들었어요',
icon: created == null ? RupIconName.alert : RupIconName.check);
} else {
await notifier.updateDiary(editing.copyWith(
name: name.isEmpty ? null : name,
// 비워 두면 기존 대상을 유지한다 (시안)
targetIds: _targets.isEmpty ? null : List.of(_targets),
ribbon: _ribbon,
coverPath: _cover,
));
toast.show('다이어리 정보를 수정했어요');
}
if (mounted) Navigator.of(context).pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final names = ref.watch(targetNamesProvider);
const gap = SizedBox(height: AppSpacing.lg);
return SingleChildScrollView(
clipBehavior: Clip.none,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_editing) ...[
Text(
'반려동물별로, 주제별로 다이어리를 나눠 보세요 · 최대 $kMaxDiaries개',
textAlign: TextAlign.center,
style: _ui(context, 12.5, FontWeight.w400, AppColors.muted),
),
const SizedBox(height: 14),
],
PetPhotoSlot(
photoPath: _cover,
onPick: () => unawaited(_pickCover()),
height: _editing ? _kEditCoverHeight : null,
radius: _kCoverRadius,
label: _editing ? '표지 사진 바꾸기' : '다이어리 표지 이미지 추가',
),
gap,
RupField(
label: '다이어리 이름',
child: RupTextField(
controller: _name,
hintText: _editing ? '예: 콩이 성장일기' : '다이어리 이름을 입력해주세요.',
maxLength: kDiaryNameMaxLength,
),
),
if (!_editing) ...[
gap,
RupField(
label: '다이어리 종류',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RupSegmented<bool>(
options: const [(false, '개인 다이어리'), (true, '공유 다이어리')],
value: false,
onChanged: (shared) {
if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
},
),
const SizedBox(height: 7),
Text(
'나만 기록하는 다이어리예요. 나중에 공유로 바꿀 수 있어요.',
style: _ui(context, 11.5, FontWeight.w400, AppColors.muted).copyWith(height: 1.45),
),
],
),
),
],
gap,
RupField(
label: '누구의 다이어리인가요?',
labelSuffix: '(여러 명 선택 가능)',
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Row(
children: [
for (final (i, id) in [kSelfTargetId, ...pets.map((p) => p.id)].indexed) ...[
if (i > 0) const SizedBox(width: 10),
TargetChip(
targetId: id,
name: names[id] ?? '',
selected: _targets.contains(id),
showCheck: true,
onTap: () => setState(() => _targets.contains(id) ? _targets.remove(id) : _targets.add(id)),
),
],
],
),
),
),
gap,
RupField(
label: '책갈피 리본 색상',
child: RibbonPicker(color: _ribbon, onPick: (c) => setState(() => _ribbon = c), onCustom: _pickCustomRibbon),
),
RupSheetBleedButton(
label: _editing ? '수정 완료' : '다이어리 만들기',
onPressed: () => unawaited(_submit()),
),
],
),
);
}
}
/// 책갈피 리본 색 — 기본 6색 + 직접 고르기
class RibbonPicker extends StatelessWidget {
const RibbonPicker({super.key, required this.color, required this.onPick, required this.onCustom});
final Color color;
final ValueChanged<Color> onPick;
final VoidCallback onCustom;
static const double _size = 36;
static const _rainbow = SweepGradient(colors: [
Color(0xFFFF7500), Color(0xFFFF7AA0), Color(0xFF8E7CF0), Color(0xFF4DA6E0),
Color(0xFF3FB58C), Color(0xFFFFD43B), Color(0xFFFF7500),
]);
@override
Widget build(BuildContext context) {
final custom = !kRibbonColors.contains(color);
Widget check(Color on) =>
RupIcon(RupIconName.check, size: 17, stroke: 2.8, color: isLightColor(on) ? const Color(0xFF3A352F) : Colors.white);
List<BoxShadow> ring() => const [
BoxShadow(color: AppColors.primary, spreadRadius: 4.5),
BoxShadow(color: AppColors.surface, spreadRadius: 2.5),
];
return Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final c in kRibbonColors)
Semantics(
button: true,
selected: c == color,
excludeSemantics: true,
child: GestureDetector(
onTap: () => onPick(c),
child: Container(
width: _size,
height: _size,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
// 흰 리본은 바탕과 구분되도록 얇은 테두리
border: c == Colors.white && c != color ? Border.all(color: AppColors.line, width: 1.5) : null,
boxShadow: c == color ? ring() : null,
),
alignment: Alignment.center,
child: c == color ? check(c) : null,
),
),
),
Semantics(
button: true,
label: '리본 색 직접 고르기',
excludeSemantics: true,
child: GestureDetector(
onTap: onCustom,
child: Container(
width: _size,
height: _size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: custom ? color : null,
gradient: custom ? null : _rainbow,
boxShadow: custom ? ring() : null,
),
alignment: Alignment.center,
child: custom
? check(color)
: const RupIcon(
RupIconName.plus,
size: 16,
stroke: 2.6,
color: Colors.white,
),
),
),
),
],
);
}
}
/// 기록 관리 — 수정 / 삭제
enum PostAction { edit, delete }
Future<PostAction?> showPostMenuSheet(BuildContext context) {
return showRupSheet<PostAction>(
context,
title: '기록 관리',
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetChoiceCard(
icon: const RupIcon(RupIconName.edit, size: 20, stroke: 2),
iconColor: AppColors.primary600,
iconBackground: AppColors.primary50,
iconBox: 42,
padding: 15,
title: '수정하기',
showChevron: false,
onTap: () => Navigator.of(context).pop(PostAction.edit),
),
const SizedBox(height: 10),
SheetChoiceCard(
icon: const RupIcon(RupIconName.x, size: 20, stroke: 2.4),
iconColor: kDangerTone,
iconBackground: kDangerTone.withValues(alpha: .12),
iconBox: 42,
padding: 15,
title: '삭제하기',
titleColor: kDangerTone,
showChevron: false,
onTap: () => Navigator.of(context).pop(PostAction.delete),
),
],
),
);
}
/// 지우기·나가기 등 위험한 동작의 색 (시안 #E05A4A)
const Color kDangerTone = Color(0xFFE05A4A);
/// 기록 글 수정 — 저장하면 새 글, 닫으면 null
Future<String?> showEditPostSheet(BuildContext context, String initial) {
return showRupSheet<String>(
context,
title: '기록 수정',
flushBottom: true,
builder: (context) => _EditPostBody(initial: initial),
);
}
class _EditPostBody extends StatefulWidget {
const _EditPostBody({required this.initial});
final String initial;
@override
State<_EditPostBody> createState() => _EditPostBodyState();
}
class _EditPostBodyState extends State<_EditPostBody> {
late final _text = TextEditingController(text: widget.initial);
@override
void dispose() {
_text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
clipBehavior: Clip.none,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppSpacing.sm),
RupField(
label: '오늘 하루는 어떠셨나요?',
child: RupTextField(controller: _text, multiline: true),
),
RupSheetBleedButton(
label: '수정 완료',
// 비우면 원래 글을 그대로 둔다 (시안)
onPressed: () => Navigator.of(context).pop(_text.text.trim().isEmpty ? widget.initial : _text.text.trim()),
),
],
),
);
}
}
@@ -0,0 +1,176 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.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/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/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/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
/// 홈 다이어리 그리드 — 최근 기록 3개
const int _kHomeDiaryCount = 3;
const double _kHomeTileRadius = 8;
/// 글만 있는 기록의 따옴표 색 (시안 #D85A30, 60%)
const Color _kQuoteColor = Color(0x99D85A30);
/// 따옴표 글리프 (시안 QuoteMark)
const String _kQuoteSvg =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 13.6C4 9.9 6.3 7.2 9.7 6l.7 1.7C8.1 8.6 6.9 9.9 6.7 11.6c.3-.1.7-.2 1.1-.2 1.7 0 2.9 1.3 2.9 3s-1.3 3-3.1 3C5.4 17.4 4 15.8 4 13.6Z M13.6 13.6C13.6 9.9 15.9 7.2 19.3 6l.7 1.7c-2.3.9-3.5 2.2-3.7 3.9.3-.1.7-.2 1.1-.2 1.7 0 2.9 1.3 2.9 3s-1.3 3-3.1 3C15 17.4 13.6 15.8 13.6 13.6Z"/></svg>';
/// 일정·기록 → 다이어리 — 나의 다이어리 표지 그리드
class DiaryShelf extends ConsumerWidget {
const DiaryShelf({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final book = ref.watch(diaryBookProvider);
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Row(
children: [
const Expanded(child: SectionTitle(title: '나의 다이어리')),
Text(
'${book.diaries.length}/$kMaxDiaries',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
height: AppTypography.normalHeight,
color: book.atMax ? AppColors.primary600 : AppColors.muted,
),
),
],
),
),
const SizedBox(height: AppSpacing.md),
DiaryGrid(
children: [
for (final d in book.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))),
],
),
],
);
}
}
/// 홈 "다이어리" — 최근 기록 3개 (사진 / 글)
class HomeDiaryGrid extends ConsumerWidget {
const HomeDiaryGrid({super.key});
@override
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 today = dateOnly(DateTime.now());
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < _kHomeDiaryCount; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(
child: i < latest.length
? _HomeDiaryTile(post: latest[i], book: book, names: names, today: today)
: const SizedBox.shrink(),
),
],
],
);
}
}
class _HomeDiaryTile extends StatelessWidget {
const _HomeDiaryTile({required this.post, required this.book, required this.names, required this.today});
final DiaryPost post;
final DiaryBook book;
final Map<String, String> names;
final DateTime today;
@override
Widget build(BuildContext context) {
final diary = book.diary(post.diaryId);
// 누구의 기록인지 — 다이어리의 첫 아이, 없으면 다이어리 이름
final who = diary == null ? '' : (diary.targetIds.map((id) => names[id]).nonNulls.firstOrNull ?? diary.name);
final photo = post.photoPath;
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
label: '$who ${relativeDayLabel(post.date, today)} 기록',
excludeSemantics: true,
child: GestureDetector(
onTap: () => unawaited(context.push(AppRoutes.diary(post.diaryId))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(_kHomeTileRadius),
child: photo != null
? ColoredBox(color: AppColors.primary50, child: PetPhotoImage(path: photo))
: ColoredBox(
color: AppColors.sand,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SvgPicture.string(
_kQuoteSvg,
width: 15,
height: 15,
colorFilter: const ColorFilter.mode(_kQuoteColor, BlendMode.srcIn),
),
const SizedBox(height: 4),
Text(
post.text,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall!.copyWith(fontSize: 11, height: 1.45, color: AppColors.sandInk),
),
],
),
),
),
),
),
const SizedBox(height: 6),
Text(
'$who · ${relativeDayLabel(post.date, today)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall!.copyWith(fontSize: 11, color: AppColors.muted, height: AppTypography.normalHeight),
),
],
),
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 기록의 ⋮ — 수정하기 / 삭제하기
Future<void> managePost(BuildContext context, WidgetRef ref, DiaryPost post) async {
final action = await showPostMenuSheet(context);
if (action == null || !context.mounted) return;
final notifier = ref.read(diaryProvider.notifier);
final toast = ref.read(toastProvider.notifier);
switch (action) {
case PostAction.edit:
final text = await showEditPostSheet(context, post.text);
if (text == null) return;
await notifier.updatePostText(post.id, text);
toast.show('기록을 수정했어요');
case PostAction.delete:
final ok = await showRupConfirm(
context,
title: '이 기록을 삭제할까요?',
message: '삭제한 기록은 다시 되돌릴 수 없어요.',
confirmLabel: '삭제하기',
layout: RupConfirmLayout.row,
icon: RupIconName.alert,
iconColor: kDangerTone,
);
if (!ok) return;
await notifier.deletePost(post.id);
toast.show('기록을 삭제했어요');
}
}
@@ -5,11 +5,11 @@ 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/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/records/presentation/providers/records_tab_provider.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';
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (시안 .rup-screen 아래 124 에서 탭바 몫을 뺀 값)
@@ -69,7 +69,7 @@ class HomePage extends ConsumerWidget {
),
),
const SizedBox(height: 19),
padded(const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요'))),
padded(const HomeDiaryGrid()),
],
),
);
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
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';
@@ -32,6 +33,7 @@ class AccountActions {
..invalidate(userBackgroundsProvider)
..invalidate(profileProvider)
..invalidate(scheduleProvider)
..invalidate(diaryProvider)
..invalidate(scheduleDateProvider)
..invalidate(carePresetsProvider)
..invalidate(recordsTabProvider)
@@ -9,6 +9,7 @@ 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/core/utils/korean.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/data/repositories/allergen_menu_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
@@ -267,6 +268,7 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
// 아이의 펫 명함도 함께 지우고, 일정에서는 그 아이만 뗀다
await ref.read(petCardsProvider.notifier).deleteForPet(editing.id);
await ref.read(scheduleProvider.notifier).dropTarget(editing.id);
await ref.read(diaryProvider.notifier).dropTarget(editing.id);
ref.read(toastProvider.notifier).show('반려동물을 삭제했어요');
if (mounted) context.go(AppRoutes.myPets);
}
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_shelf.dart';
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
import 'package:tma_app/features/schedule/presentation/widgets/schedule_view.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/seg_toggle.dart';
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
@@ -43,11 +42,7 @@ class RecordsPage extends ConsumerWidget {
duration: const Duration(milliseconds: 300),
child: tab == RecordsTab.schedule
? const ScheduleView(key: ValueKey(RecordsTab.schedule))
// 다이어리는 다음 단계에서 만든다
: const RupCard(
key: ValueKey(RecordsTab.diary),
child: EmptyMessage('다이어리는 준비 중이에요', padding: 40),
),
: const DiaryShelf(key: ValueKey(RecordsTab.diary)),
),
],
),