From d7f46d174a12b5cf5bb019de2995033d89575382 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 22:58:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=A6=AC=20=EC=B1=85=EC=9E=A5=C2=B7=EA=B8=B0=EB=A1=9D=C2=B7?= =?UTF-8?q?=EB=8C=93=EA=B8=80=C2=B7=EB=8B=AC=EB=A0=A5=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=ED=99=88=20=EC=B5=9C=EA=B7=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일정 관리 다음 흐름 — 일정·기록 탭의 다이어리. - 책장: 표지 그리드(최대 6권), 반투명 책갈피 리본, 새 다이어리 만들기(표지·이름·대상·리본 색) - 다이어리 안: 기록 피드(모두/사진/글) · 검색 · 달력 보기(날마다 기분 표정) · 하루 한 번 쓰기 버튼 - 기록하기: 사진 · 오늘 기분 6종 · 글 / 기록 수정·삭제 / 댓글 작성·수정·삭제 - 다이어리 설정: 정보 수정, 알림 스위치(값만 저장), 다이어리 제거(기록·댓글 함께) - 홈 다이어리: 최근 기록 3개를 사진/글 타일로, 누르면 그 다이어리로 - 아이를 지우면 다이어리 대상에서만 떼고, 회원탈퇴는 다이어리도 지운다 - 기록은 기기에 저장한다 (공유 다이어리 서버가 생기면 DiaryRepository 구현을 바꾼다) 시안과 다른 점: 공유 다이어리(초대 코드·QR·멤버·참여)는 서버가 필요해 안내만 둔다. Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/assets/svg/mood/mood_calm.svg | 9 + flutter/assets/svg/mood/mood_excited.svg | 12 + flutter/assets/svg/mood/mood_happy.svg | 9 + flutter/assets/svg/mood/mood_meh.svg | 9 + flutter/assets/svg/mood/mood_sad.svg | 11 + flutter/assets/svg/mood/mood_sick.svg | 10 + flutter/lib/core/router/app_router.dart | 20 + flutter/lib/core/router/app_routes.dart | 6 + .../features/diary/data/diary_repository.dart | 46 ++ flutter/lib/features/diary/domain/diary.dart | 200 ++++++ .../lib/features/diary/domain/diary_book.dart | 85 +++ flutter/lib/features/diary/domain/mood.dart | 21 + .../pages/diary_compose_page.dart | 179 ++++++ .../diary/presentation/pages/diary_page.dart | 603 ++++++++++++++++++ .../pages/diary_settings_page.dart | 191 ++++++ .../providers/diary_providers.dart | 84 +++ .../presentation/widgets/diary_cover.dart | 291 +++++++++ .../presentation/widgets/diary_post_card.dart | 520 +++++++++++++++ .../presentation/widgets/diary_sheets.dart | 491 ++++++++++++++ .../presentation/widgets/diary_shelf.dart | 176 +++++ .../presentation/widgets/post_actions.dart | 36 ++ .../home/presentation/pages/home_page.dart | 4 +- .../providers/account_actions.dart | 2 + .../presentation/pages/pet_register_page.dart | 2 + .../presentation/pages/records_page.dart | 9 +- .../shared/widgets/icons/rup_icon_paths.dart | 8 +- flutter/pubspec.yaml | 1 + flutter/test/diary_domain_test.dart | 108 ++++ flutter/test/diary_flow_test.dart | 162 +++++ 29 files changed, 3295 insertions(+), 10 deletions(-) create mode 100644 flutter/assets/svg/mood/mood_calm.svg create mode 100644 flutter/assets/svg/mood/mood_excited.svg create mode 100644 flutter/assets/svg/mood/mood_happy.svg create mode 100644 flutter/assets/svg/mood/mood_meh.svg create mode 100644 flutter/assets/svg/mood/mood_sad.svg create mode 100644 flutter/assets/svg/mood/mood_sick.svg create mode 100644 flutter/lib/features/diary/data/diary_repository.dart create mode 100644 flutter/lib/features/diary/domain/diary.dart create mode 100644 flutter/lib/features/diary/domain/diary_book.dart create mode 100644 flutter/lib/features/diary/domain/mood.dart create mode 100644 flutter/lib/features/diary/presentation/pages/diary_compose_page.dart create mode 100644 flutter/lib/features/diary/presentation/pages/diary_page.dart create mode 100644 flutter/lib/features/diary/presentation/pages/diary_settings_page.dart create mode 100644 flutter/lib/features/diary/presentation/providers/diary_providers.dart create mode 100644 flutter/lib/features/diary/presentation/widgets/diary_cover.dart create mode 100644 flutter/lib/features/diary/presentation/widgets/diary_post_card.dart create mode 100644 flutter/lib/features/diary/presentation/widgets/diary_sheets.dart create mode 100644 flutter/lib/features/diary/presentation/widgets/diary_shelf.dart create mode 100644 flutter/lib/features/diary/presentation/widgets/post_actions.dart create mode 100644 flutter/test/diary_domain_test.dart create mode 100644 flutter/test/diary_flow_test.dart diff --git a/flutter/assets/svg/mood/mood_calm.svg b/flutter/assets/svg/mood/mood_calm.svg new file mode 100644 index 0000000..3841415 --- /dev/null +++ b/flutter/assets/svg/mood/mood_calm.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/mood/mood_excited.svg b/flutter/assets/svg/mood/mood_excited.svg new file mode 100644 index 0000000..a2210d3 --- /dev/null +++ b/flutter/assets/svg/mood/mood_excited.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/mood/mood_happy.svg b/flutter/assets/svg/mood/mood_happy.svg new file mode 100644 index 0000000..34e9b34 --- /dev/null +++ b/flutter/assets/svg/mood/mood_happy.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/mood/mood_meh.svg b/flutter/assets/svg/mood/mood_meh.svg new file mode 100644 index 0000000..b8459c8 --- /dev/null +++ b/flutter/assets/svg/mood/mood_meh.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/mood/mood_sad.svg b/flutter/assets/svg/mood/mood_sad.svg new file mode 100644 index 0000000..3313a82 --- /dev/null +++ b/flutter/assets/svg/mood/mood_sad.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/mood/mood_sick.svg b/flutter/assets/svg/mood/mood_sick.svg new file mode 100644 index 0000000..582da91 --- /dev/null +++ b/flutter/assets/svg/mood/mood_sick.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 896770f..dd7145f 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -5,6 +5,9 @@ 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/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'; +import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; import 'package:tma_app/features/home/presentation/pages/home_page.dart'; import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; @@ -94,6 +97,23 @@ final appRouterProvider = Provider((ref) { parentNavigatorKey: rootKey, builder: (context, state) => EventEditorPage.edit(eventId: state.uri.queryParameters['id'] ?? ''), ), + GoRoute( + path: AppRoutes.diaryPattern, + parentNavigatorKey: rootKey, + builder: (context, state) => DiaryPage(diaryId: state.pathParameters['diaryId']!), + routes: [ + GoRoute( + path: 'write', + parentNavigatorKey: rootKey, + builder: (context, state) => DiaryComposePage(diaryId: state.pathParameters['diaryId']!), + ), + GoRoute( + path: 'settings', + parentNavigatorKey: rootKey, + builder: (context, state) => DiarySettingsPage(diaryId: state.pathParameters['diaryId']!), + ), + ], + ), // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다 GoRoute( path: AppRoutes.petCardManage, diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 40e25c7..73700d0 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -32,6 +32,12 @@ 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 diaryPattern = '/diaries/:diaryId'; + static String diary(String id) => '/diaries/$id'; + static String diaryWrite(String id) => '/diaries/$id/write'; + static String diarySettings(String id) => '/diaries/$id/settings'; + static const String petCardManage = '/pet-cards/manage'; static const String petCardCreatePattern = '/pet-cards/new/:petId'; static const String petCardDetailPattern = '/pet-cards/:cardId'; diff --git a/flutter/lib/features/diary/data/diary_repository.dart b/flutter/lib/features/diary/data/diary_repository.dart new file mode 100644 index 0000000..6a23a58 --- /dev/null +++ b/flutter/lib/features/diary/data/diary_repository.dart @@ -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 fetch(); + + Future save(DiaryBook book); +} + +const String _kDiariesKey = 'diary.diaries.v1'; +const String _kPostsKey = 'diary.posts.v1'; +const String _kCommentsKey = 'diary.comments.v1'; + +List> _decode(String? raw) => + raw == null ? const [] : (jsonDecode(raw) as List).whereType>().toList(); + +/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다. +class LocalDiaryRepository implements DiaryRepository { + LocalDiaryRepository(this._prefs); + + final SharedPreferences _prefs; + + @override + Future 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 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((ref) { + return LocalDiaryRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/diary/domain/diary.dart b/flutter/lib/features/diary/domain/diary.dart new file mode 100644 index 0000000..d1b1123 --- /dev/null +++ b/flutter/lib/features/diary/domain/diary.dart @@ -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 kRibbonColors = [ + Color(0xFFFFFFFF), Color(0xFF2B2622), Color(0xFFFF7500), + Color(0xFF4DA6E0), Color(0xFF3FB58C), Color(0xFFFF7AA0), +]; + +/// 표지 사진이 없을 때 바탕색 — 만들 때 하나를 고른다 +const List 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 targetIds; + final Color ribbon; + final Color tint; + final String? coverPath; + + /// 끈 알림 (기본은 모두 켜짐) + final Set mutedNotices; + + bool notifies(DiaryNotice n) => !mutedNotices.contains(n); + + Diary copyWith({ + String? name, + List? targetIds, + Color? ribbon, + String? coverPath, + Set? 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 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 json) => Diary( + id: json['id']! as String, + name: json['name']! as String, + targetIds: (json['targets'] as List?)?.whereType().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() ?? const []) + ?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 toJson() => { + 'id': id, + 'diary': diaryId, + 'date': toIsoDate(date), + 'createdAt': createdAt.toIso8601String(), + 'text': text, + 'mood': mood?.name, + 'photo': photoPath, + 'author': authorId, + }; + + factory DiaryPost.fromJson(Map 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 toJson() => { + 'id': id, + 'post': postId, + 'text': text, + 'createdAt': createdAt.toIso8601String(), + 'author': authorId, + }; + + factory DiaryComment.fromJson(Map 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, + ); +} diff --git a/flutter/lib/features/diary/domain/diary_book.dart b/flutter/lib/features/diary/domain/diary_book.dart new file mode 100644 index 0000000..befff3f --- /dev/null +++ b/flutter/lib/features/diary/domain/diary_book.dart @@ -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 diaries; + final List posts; + final List 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 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 latest(int limit) => (List.of(posts)..sort(_newestFirst)).take(limit).toList(); + + List commentsOf(String postId) => + comments.where((c) => c.postId == postId).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + DiaryBook copyWith({List? diaries, List? posts, List? 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()); +} diff --git a/flutter/lib/features/diary/domain/mood.dart b/flutter/lib/features/diary/domain/mood.dart new file mode 100644 index 0000000..a074037 --- /dev/null +++ b/flutter/lib/features/diary/domain/mood.dart @@ -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; +} diff --git a/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart b/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart new file mode 100644 index 0000000..7e45a86 --- /dev/null +++ b/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart @@ -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 createState() => _DiaryComposePageState(); +} + +class _DiaryComposePageState extends ConsumerState { + final _text = TextEditingController(); + Mood _mood = Mood.happy; + String? _photo; + bool _saving = false; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + Future _pickPhoto() async { + final path = await pickAndStorePhoto(ref.read(petPhotoStoreProvider)); + if (path == null || !mounted) return; + setState(() => _photo = path); + } + + Future _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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/diary/presentation/pages/diary_page.dart b/flutter/lib/features/diary/presentation/pages/diary_page.dart new file mode 100644 index 0000000..93fa7f5 --- /dev/null +++ b/flutter/lib/features/diary/presentation/pages/diary_page.dart @@ -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 createState() => _DiaryPageState(); +} + +class _DiaryPageState extends ConsumerState { + 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 _feedBody(List posts, {required bool searching, required bool wroteToday}) { + Widget menu(DiaryPost p) => _PostView(post: p, mode: _mode); + final shown = _mode == FeedMode.image ? posts.where((p) => p.hasPhoto).toList() : posts; + 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 _calendarBody(Diary diary, List 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 posts; + final ValueChanged onMonth; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final lead = month.weekday % 7; + final days = daysInMonth(month.year, month.month); + final cells = [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 = {}; + 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 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))), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart new file mode 100644 index 0000000..d1737e4 --- /dev/null +++ b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart @@ -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 _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( + 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, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/diary/presentation/providers/diary_providers.dart b/flutter/lib/features/diary/presentation/providers/diary_providers.dart new file mode 100644 index 0000000..d3f7ee0 --- /dev/null +++ b/flutter/lib/features/diary/presentation/providers/diary_providers.dart @@ -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 { + DiaryRepository get _repo => ref.read(diaryRepositoryProvider); + int _serial = 0; + + @override + Future build() => ref.watch(diaryRepositoryProvider).fetch(); + + DiaryBook get _book => state.value ?? DiaryBook.empty; + + String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}'; + + Future _commit(DiaryBook next) async { + await _repo.save(next); + if (ref.mounted) state = AsyncData(next); + } + + /// 새 다이어리 — 가득 찼으면 null + Future createDiary({ + required String name, + required List 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 updateDiary(Diary diary) => _commit(_book.updateDiary(diary)); + + Future deleteDiary(String id) => _commit(_book.deleteDiary(id)); + + Future 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 updatePostText(String id, String text) => _commit(_book.updatePostText(id, text)); + + Future deletePost(String id) => _commit(_book.deletePost(id)); + + Future addComment(String postId, String text) => _commit(_book.addComment( + DiaryComment(id: _newId('c'), postId: postId, text: text, createdAt: DateTime.now()), + )); + + Future updateComment(String id, String text) => _commit(_book.updateComment(id, text)); + + Future deleteComment(String id) => _commit(_book.deleteComment(id)); + + /// 아이를 지우면 다이어리 대상에서만 뗀다 + Future dropTarget(String targetId) => _commit(_book.dropTarget(targetId)); +} + +final diaryProvider = AsyncNotifierProvider(DiaryNotifier.new); + +/// 불러오기 전에도 쓸 수 있는 다이어리 (빈 목록으로 시작) +final diaryBookProvider = Provider((ref) => ref.watch(diaryProvider).value ?? DiaryBook.empty); diff --git a/flutter/lib/features/diary/presentation/widgets/diary_cover.dart b/flutter/lib/features/diary/presentation/widgets/diary_cover.dart new file mode 100644 index 0000000..81c895f --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/diary_cover.dart @@ -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 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 { + 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 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)], + ); + }, + ); + } +} diff --git a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart new file mode 100644 index 0000000..480631a --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart @@ -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 _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 createState() => _PolaroidPostState(); +} + +class _PolaroidPostState extends State { + 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 _add() async { + final text = _draft.text.trim(); + if (text.isEmpty) return; + _draft.clear(); + await ref.read(diaryProvider.notifier).addComment(widget.postId, text); + } + + Future _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 _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 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), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart new file mode 100644 index 0000000..ce07d89 --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart @@ -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 showAddDiarySheet(BuildContext context, WidgetRef ref) { + return showRupSheet( + 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 showDiaryFormSheet(BuildContext context, {Diary? editing}) { + return showRupSheet( + 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 _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 _pickCover() async { + final path = await pickAndStorePhoto(ref.read(petPhotoStoreProvider)); + if (path == null || !mounted) return; + setState(() => _cover = path); + } + + Future _pickCustomRibbon() async { + final c = await showColorPickSheet(context, title: '리본 색상', initial: _ribbon); + if (c == null || !mounted) return; + setState(() => _ribbon = c); + } + + Future _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 []; + 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( + 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 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 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 showPostMenuSheet(BuildContext context) { + return showRupSheet( + 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 showEditPostSheet(BuildContext context, String initial) { + return showRupSheet( + 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()), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart new file mode 100644 index 0000000..fb8bb79 --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart @@ -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 = + ''; + +/// 일정·기록 → 다이어리 — 나의 다이어리 표지 그리드 +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 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), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/diary/presentation/widgets/post_actions.dart b/flutter/lib/features/diary/presentation/widgets/post_actions.dart new file mode 100644 index 0000000..b2d206f --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/post_actions.dart @@ -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 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('기록을 삭제했어요'); + } +} diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index a102e0b..54ebc76 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -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()), ], ), ); diff --git a/flutter/lib/features/mypage/presentation/providers/account_actions.dart b/flutter/lib/features/mypage/presentation/providers/account_actions.dart index b89bd50..ce29bf8 100644 --- a/flutter/lib/features/mypage/presentation/providers/account_actions.dart +++ b/flutter/lib/features/mypage/presentation/providers/account_actions.dart @@ -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) diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart index 5bc8c9e..b13d169 100644 --- a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart +++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart @@ -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); } diff --git a/flutter/lib/features/records/presentation/pages/records_page.dart b/flutter/lib/features/records/presentation/pages/records_page.dart index bf49b3c..5c84c0c 100644 --- a/flutter/lib/features/records/presentation/pages/records_page.dart +++ b/flutter/lib/features/records/presentation/pages/records_page.dart @@ -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)), ), ], ), diff --git a/flutter/lib/shared/widgets/icons/rup_icon_paths.dart b/flutter/lib/shared/widgets/icons/rup_icon_paths.dart index cd0d752..9e80dd9 100644 --- a/flutter/lib/shared/widgets/icons/rup_icon_paths.dart +++ b/flutter/lib/shared/widgets/icons/rup_icon_paths.dart @@ -193,7 +193,13 @@ enum RupGlyph { gear(24, '', stroke: 2.2), /// QR 코드 - qr(15, '', fill: true); + qr(15, '', fill: true), + + /// 공유 다이어리 책갈피 + diaryShared(24, '', stroke: 2), + + /// 개인 다이어리 책갈피 (자물쇠) + diaryPrivate(24, '', stroke: 2); const RupGlyph(this.viewBox, this.paths, {this.fill = false, this.stroke}); diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 0f04621..10fb933 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -79,6 +79,7 @@ flutter: - assets/svg/login/ - assets/svg/common/ - assets/svg/species/ + - assets/svg/mood/ - assets/svg/card_bg/ - assets/images/brand/ diff --git a/flutter/test/diary_domain_test.dart b/flutter/test/diary_domain_test.dart new file mode 100644 index 0000000..f53df3a --- /dev/null +++ b/flutter/test/diary_domain_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.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/domain/mood.dart'; + +Diary _diary(String id, {List targets = const ['pet-1']}) => + Diary(id: id, name: id, targetIds: targets, ribbon: kRibbonColors.first, tint: kDiaryTints.first); + +DiaryPost _post(String id, String diaryId, DateTime date, {int minute = 0}) => DiaryPost( + id: id, + diaryId: diaryId, + date: date, + createdAt: date.add(Duration(minutes: minute)), + text: id, + mood: Mood.happy, + ); + +void main() { + group('relativeDayLabel', () { + final today = DateTime(2026, 9, 28); + + test('오늘 · 어제 · 일주일 안은 N일 전', () { + expect(relativeDayLabel(today, today), '오늘'); + expect(relativeDayLabel(DateTime(2026, 9, 27), today), '어제'); + expect(relativeDayLabel(DateTime(2026, 9, 22), today), '6일 전'); + }); + + test('일주일이 넘으면 날짜로', () { + expect(relativeDayLabel(DateTime(2026, 9, 21), today), '2026.09.21'); + }); + }); + + group('DiaryBook', () { + final day1 = DateTime(2026, 9, 27); + final day2 = DateTime(2026, 9, 28); + + test('기록은 최근 날짜부터, 같은 날이면 나중에 쓴 것부터', () { + final book = DiaryBook(diaries: [_diary('d1')], posts: [ + _post('old', 'd1', day1), + _post('early', 'd1', day2), + _post('late', 'd1', day2, minute: 5), + ]); + expect(book.postsOf('d1').map((p) => p.id), ['late', 'early', 'old']); + }); + + test('하루 한 번 — 그날 쓴 기록이 있는지', () { + final book = DiaryBook(posts: [_post('p', 'd1', day2)]); + expect(book.wroteOn('d1', day2), isTrue); + expect(book.wroteOn('d1', day1), isFalse); + expect(book.wroteOn('d2', day2), isFalse); + }); + + test('다이어리를 지우면 그 기록과 댓글도 지운다', () { + final book = DiaryBook( + diaries: [_diary('d1'), _diary('d2')], + posts: [_post('p1', 'd1', day1), _post('p2', 'd2', day1)], + comments: [ + DiaryComment(id: 'c1', postId: 'p1', text: '!', createdAt: day1), + DiaryComment(id: 'c2', postId: 'p2', text: '!', createdAt: day1), + ], + ).deleteDiary('d1'); + expect(book.diaries.map((d) => d.id), ['d2']); + expect(book.posts.map((p) => p.id), ['p2']); + expect(book.comments.map((c) => c.id), ['c2']); + }); + + test('기록을 지우면 그 댓글도 지운다', () { + final book = DiaryBook( + posts: [_post('p1', 'd1', day1)], + comments: [DiaryComment(id: 'c1', postId: 'p1', text: '!', createdAt: day1)], + ).deletePost('p1'); + expect(book.comments, isEmpty); + }); + + test('아이를 지우면 다이어리 대상에서만 떼고 기록은 남긴다', () { + final book = DiaryBook( + diaries: [_diary('d1', targets: ['pet-1', 'self'])], + posts: [_post('p1', 'd1', day1)], + ).dropTarget('pet-1'); + expect(book.diaries.single.targetIds, ['self']); + expect(book.posts, hasLength(1)); + }); + + test('홈 — 모든 다이어리의 최근 기록', () { + final book = DiaryBook(posts: [ + _post('a', 'd1', day1), + _post('b', 'd2', day2), + _post('c', 'd1', day2, minute: 3), + _post('d', 'd2', DateTime(2026, 9, 20)), + ]); + expect(book.latest(3).map((p) => p.id), ['c', 'b', 'a']); + }); + + test('다이어리는 6권까지', () { + final book = DiaryBook(diaries: [for (var i = 0; i < kMaxDiaries; i++) _diary('d$i')]); + expect(book.atMax, isTrue); + }); + }); + + test('다이어리는 끈 알림까지 저장 형식으로 되살린다', () { + final d = _diary('d1').copyWith(mutedNotices: {DiaryNotice.reminder}, coverPath: '/c.jpg'); + final back = Diary.fromJson(d.toJson()); + expect(back.notifies(DiaryNotice.reminder), isFalse); + expect(back.notifies(DiaryNotice.newPost), isTrue); + expect(back.coverPath, '/c.jpg'); + }); +} diff --git a/flutter/test/diary_flow_test.dart b/flutter/test/diary_flow_test.dart new file mode 100644 index 0000000..95e1e04 --- /dev/null +++ b/flutter/test/diary_flow_test.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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/mood.dart'; +import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dart'; +import 'package:tma_app/features/diary/presentation/pages/diary_page.dart'; +import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart'; + +import 'support/app_harness.dart'; + +final Diary _diary = Diary( + id: 'd1', + name: '콩이 성장일기', + targetIds: const ['pet-1'], + ribbon: kRibbonColors.first, + tint: kDiaryTints.first, +); + +DiaryPost _post(String text, DateTime date) => + DiaryPost(id: 'p-$text', diaryId: 'd1', date: date, createdAt: date, text: text, mood: Mood.calm); + +Map _prefs({List posts = const [], bool withDiary = true}) => { + 'pets.v1': jsonEncode([samplePet().toJson()]), + if (withDiary) 'diary.diaries.v1': jsonEncode([_diary.toJson()]), + if (posts.isNotEmpty) 'diary.posts.v1': jsonEncode([for (final p in posts) p.toJson()]), + }; + +Future _openShelf(WidgetTester tester) async { + await tester.tap(find.bySemanticsLabel('일정·기록')); + await settle(tester); + await tester.tap(find.text('다이어리')); + await settle(tester); +} + +Future _openDiary(WidgetTester tester) async { + await _openShelf(tester); + await tester.tap(find.text('콩이 성장일기')); + await settle(tester); + expect(find.byType(DiaryPage), findsOneWidget); +} + +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pump(); + await tester.tap(finder); + await settle(tester); +} + +void main() { + testWidgets('다이어리 탭에서 새 다이어리를 만들면 책장에 나타난다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _prefs(withDiary: false)); + await _openShelf(tester); + expect(find.text('나의 다이어리'), findsOneWidget); + expect(find.text('0/6'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('새 다이어리')); + await settle(tester); + expect(find.text('다이어리 추가'), findsOneWidget); + + await tester.tap(find.text('새 다이어리 만들기')); + await settle(tester); + await tester.enterText(find.byType(TextField).first, '보리 일기'); + await _tapVisible(tester, find.text('다이어리 만들기')); + + expect(find.text('보리 일기'), findsOneWidget); + expect(find.text('기록 0개'), findsOneWidget); + expect(find.text('1/6'), findsOneWidget); + expect(prefs.getString('diary.diaries.v1'), contains('보리 일기')); + }); + + testWidgets('오늘 기록을 남기면 피드와 홈에 나오고, 쓰기 버튼은 완료로 바뀐다', (tester) async { + await launchSignedIn(tester, extra: _prefs()); + await _openDiary(tester); + expect(find.text('아직 기록이 없어요.'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('다이어리 쓰기')); + await settle(tester); + expect(find.byType(DiaryComposePage), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('신남')); + await tester.enterText(find.byType(TextField), '산책하고 낮잠 잤어요'); + await _tapVisible(tester, find.text('기록 남기기')); + + expect(find.byType(DiaryComposePage), findsNothing); + expect(find.text('산책하고 낮잠 잤어요'), findsOneWidget); + expect(find.text('기록 1개'), findsOneWidget); + + // 하루 한 번 — 다시 누르면 안내만 + await tester.tap(find.bySemanticsLabel('오늘 작성 완료')); + await settle(tester); + expect(find.text('오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요'), findsOneWidget); + expect(find.byType(DiaryComposePage), findsNothing); + + await tester.tap(find.bySemanticsLabel('뒤로')); + await settle(tester); + await tester.tap(find.bySemanticsLabel('홈')); + await settle(tester); + await tester.scrollUntilVisible(find.text('산책하고 낮잠 잤어요'), 300, scrollable: find.byType(Scrollable).first); + expect(find.text('콩이 · 오늘'), findsOneWidget); + }); + + testWidgets('기록에 댓글을 달고, 기록을 지울 수 있다', (tester) async { + await launchSignedIn(tester, extra: _prefs(posts: [_post('어제 기록', DateTime.now().subtract(const Duration(days: 1)))])); + await _openDiary(tester); + expect(find.text('어제'), findsOneWidget); + + await tester.tap(find.text('댓글')); + await settle(tester); + expect(find.text('첫 댓글을 남겨보세요'), findsOneWidget); + await tester.enterText(find.byType(TextField).last, '귀여워요'); + await tester.tap(find.bySemanticsLabel('등록')); + await settle(tester); + expect(find.text('귀여워요'), findsOneWidget); + expect(find.text('댓글 1'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('기록 관리')); + await settle(tester); + await tester.tap(find.text('삭제하기')); + await settle(tester); + expect(find.text('이 기록을 삭제할까요?'), findsOneWidget); + await tester.tap(find.text('삭제하기').last); + await settle(tester); + expect(find.text('어제 기록'), findsNothing); + expect(find.text('아직 기록이 없어요.'), findsOneWidget); + }); + + testWidgets('달력 보기에서 날짜를 고르면 그날 기록을 보여 준다', (tester) async { + final today = dateOnly(DateTime.now()); + await launchSignedIn(tester, extra: _prefs(posts: [_post('오늘 기록', today)])); + await _openDiary(tester); + + await tester.tap(find.bySemanticsLabel('보기 전환')); + await settle(tester); + expect(find.text('${today.year}년 ${today.month}월'), findsOneWidget); + expect(find.text('${formatMonthDay(today)} · 기록 1개'), findsOneWidget); + expect(find.text('오늘 기록'), findsOneWidget); + }); + + testWidgets('다이어리 설정에서 다이어리를 제거하면 책장으로 돌아간다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _prefs(posts: [_post('기록', DateTime.now())])); + await _openDiary(tester); + + await tester.tap(find.bySemanticsLabel('다이어리 설정')); + await settle(tester); + expect(find.byType(DiarySettingsPage), findsOneWidget); + expect(find.text('알림 설정'), findsOneWidget); + + await _tapVisible(tester, find.text('다이어리 제거')); + expect(find.text('다이어리를 제거할까요?'), findsOneWidget); + await tester.tap(find.text('제거하기')); + await settle(tester); + + expect(find.byType(DiarySettingsPage), findsNothing); + expect(find.byType(DiaryPage), findsNothing); + expect(find.text('콩이 성장일기'), findsNothing); + expect(find.text('0/6'), findsOneWidget); + expect(prefs.getString('diary.posts.v1'), '[]'); + }); +}