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