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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:58:09 +09:00
parent 76e9fc1af1
commit d7f46d174a
29 changed files with 3295 additions and 10 deletions
+20
View File
@@ -5,6 +5,9 @@ import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart';
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
import 'package:tma_app/features/home/presentation/pages/home_page.dart';
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
@@ -94,6 +97,23 @@ final appRouterProvider = Provider<GoRouter>((ref) {
parentNavigatorKey: rootKey,
builder: (context, state) => EventEditorPage.edit(eventId: state.uri.queryParameters['id'] ?? ''),
),
GoRoute(
path: AppRoutes.diaryPattern,
parentNavigatorKey: rootKey,
builder: (context, state) => DiaryPage(diaryId: state.pathParameters['diaryId']!),
routes: [
GoRoute(
path: 'write',
parentNavigatorKey: rootKey,
builder: (context, state) => DiaryComposePage(diaryId: state.pathParameters['diaryId']!),
),
GoRoute(
path: 'settings',
parentNavigatorKey: rootKey,
builder: (context, state) => DiarySettingsPage(diaryId: state.pathParameters['diaryId']!),
),
],
),
// 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다
GoRoute(
path: AppRoutes.petCardManage,
+6
View File
@@ -32,6 +32,12 @@ abstract final class AppRoutes {
static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}';
static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}';
// 다이어리 — 열면 탭바 없이 전체 화면
static const String diaryPattern = '/diaries/:diaryId';
static String diary(String id) => '/diaries/$id';
static String diaryWrite(String id) => '/diaries/$id/write';
static String diarySettings(String id) => '/diaries/$id/settings';
static const String petCardManage = '/pet-cards/manage';
static const String petCardCreatePattern = '/pet-cards/new/:petId';
static const String petCardDetailPattern = '/pet-cards/:cardId';
@@ -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)),
),
],
),
@@ -193,7 +193,13 @@ enum RupGlyph {
gear(24, '<path d="M9.58295 3.8399C9.64015 3.23816 9.91964 2.67936 10.3668 2.27267C10.814 1.86598 11.3967 1.64062 12.0012 1.64062C12.6056 1.64062 13.1884 1.86598 13.6356 2.27267C14.0827 2.67936 14.3622 3.23816 14.4194 3.8399C14.4538 4.22862 14.5813 4.60334 14.7912 4.93233C15.0011 5.26133 15.2871 5.53491 15.6251 5.72994C15.9631 5.92496 16.3432 6.03568 16.733 6.05273C17.1229 6.06977 17.5111 5.99264 17.8649 5.82785C18.4141 5.57848 19.0365 5.5424 19.6109 5.72663C20.1853 5.91085 20.6707 6.30221 20.9724 6.82453C21.2742 7.34686 21.3708 7.96277 21.2435 8.55241C21.1162 9.14204 20.774 9.66321 20.2836 10.0145C19.9643 10.2386 19.7036 10.5362 19.5236 10.8824C19.3437 11.2285 19.2497 11.6129 19.2497 12.003C19.2497 12.3931 19.3437 12.7774 19.5236 13.1236C19.7036 13.4697 19.9643 13.7674 20.2836 13.9914C20.774 14.3427 21.1162 14.8639 21.2435 15.4535C21.3708 16.0431 21.2742 16.6591 20.9724 17.1814C20.6707 17.7037 20.1853 18.0951 19.6109 18.2793C19.0365 18.4635 18.4141 18.4274 17.8649 18.1781C17.5111 18.0133 17.1229 17.9361 16.733 17.9532C16.3432 17.9702 15.9631 18.081 15.6251 18.276C15.2871 18.471 15.0011 18.7446 14.7912 19.0736C14.5813 19.4026 14.4538 19.7773 14.4194 20.166C14.3622 20.7678 14.0827 21.3266 13.6356 21.7332C13.1884 22.1399 12.6056 22.3653 12.0012 22.3653C11.3967 22.3653 10.814 22.1399 10.3668 21.7332C9.91964 21.3266 9.64015 20.7678 9.58295 20.166C9.54863 19.7772 9.42111 19.4023 9.21117 19.0732C9.00124 18.7441 8.71507 18.4704 8.37692 18.2754C8.03878 18.0803 7.6586 17.9696 7.2686 17.9527C6.8786 17.9358 6.49026 18.0131 6.13648 18.1781C5.58721 18.4274 4.96481 18.4635 4.3904 18.2793C3.816 18.0951 3.33069 17.7037 3.02892 17.1814C2.72716 16.6591 2.63053 16.0431 2.75785 15.4535C2.88517 14.8639 3.22732 14.3427 3.71772 13.9914C4.03705 13.7674 4.29773 13.4697 4.47769 13.1236C4.65766 12.7774 4.75161 12.3931 4.75161 12.003C4.75161 11.6129 4.65766 11.2285 4.47769 10.8824C4.29773 10.5362 4.03705 10.2386 3.71772 10.0145C3.22801 9.66304 2.88646 9.14207 2.75945 8.55283C2.63244 7.9636 2.72904 7.34819 3.03048 6.8262C3.33192 6.30422 3.81666 5.91295 4.39049 5.72845C4.96433 5.54394 5.58627 5.57937 6.13544 5.82785C6.48918 5.99264 6.87741 6.06977 7.26727 6.05273C7.65714 6.03568 8.03716 5.92496 8.37516 5.72994C8.71317 5.53491 8.99922 5.26133 9.2091 4.93233C9.41897 4.60334 9.54649 4.22862 9.58087 3.8399"/><path d="M11.999 15.0058C13.6575 15.0058 15.0019 13.6614 15.0019 12.0029C15.0019 10.3444 13.6575 9 11.999 9C10.3405 9 8.99609 10.3444 8.99609 12.0029C8.99609 13.6614 10.3405 15.0058 11.999 15.0058Z"/>', stroke: 2.2),
/// QR 코드
qr(15, '<path fill-rule="evenodd" clip-rule="evenodd" d="M8.20312 2.10938C8.20312 1.92289 8.12905 1.74405 7.99718 1.61219C7.86532 1.48033 7.68648 1.40625 7.5 1.40625C7.31352 1.40625 7.13468 1.48033 7.00282 1.61219C6.87095 1.74405 6.79688 1.92289 6.79688 2.10938V6.79688H2.10938C1.92289 6.79688 1.74405 6.87095 1.61219 7.00282C1.48033 7.13468 1.40625 7.31352 1.40625 7.5C1.40625 7.68648 1.48033 7.86532 1.61219 7.99718C1.74405 8.12905 1.92289 8.20312 2.10938 8.20312H7.26562C7.51427 8.20312 7.75272 8.10435 7.92854 7.92854C8.10435 7.75272 8.20312 7.51427 8.20312 7.26562V2.10938ZM6.79688 10.3125C6.79688 10.0639 6.89565 9.8254 7.07146 9.64959C7.24728 9.47377 7.48573 9.375 7.73438 9.375H10.3125C10.499 9.375 10.6778 9.44908 10.8097 9.58094C10.9415 9.7128 11.0156 9.89164 11.0156 10.0781C11.0156 10.2646 10.9415 10.4434 10.8097 10.5753C10.6778 10.7072 10.499 10.7812 10.3125 10.7812H8.20312V12.8906C8.20312 13.0771 8.12905 13.2559 7.99718 13.3878C7.86532 13.5197 7.68648 13.5938 7.5 13.5938C7.31352 13.5938 7.13468 13.5197 7.00282 13.3878C6.87095 13.2559 6.79688 13.0771 6.79688 12.8906V10.3125ZM12.1875 12.1875H10.0781C9.89164 12.1875 9.7128 12.2616 9.58094 12.3934C9.44908 12.5253 9.375 12.7041 9.375 12.8906C9.375 13.0771 9.44908 13.2559 9.58094 13.3878C9.7128 13.5197 9.89164 13.5938 10.0781 13.5938H12.6562C12.9049 13.5938 13.1433 13.495 13.3192 13.3192C13.495 13.1433 13.5938 12.9049 13.5938 12.6562V7.73438C13.5938 7.48573 13.495 7.24728 13.3192 7.07146C13.1433 6.89565 12.9049 6.79688 12.6562 6.79688H10.0781C9.89164 6.79688 9.7128 6.87095 9.58094 7.00282C9.44908 7.13468 9.375 7.31352 9.375 7.5C9.375 7.68648 9.44908 7.86532 9.58094 7.99718C9.7128 8.12905 9.89164 8.20312 10.0781 8.20312H12.1875V12.1875ZM2.8125 4.21875V2.8125H4.21875V4.21875H2.8125ZM1.40625 2.34375C1.40625 2.09511 1.50502 1.85665 1.68084 1.68084C1.85665 1.50502 2.09511 1.40625 2.34375 1.40625H4.6875C4.93614 1.40625 5.1746 1.50502 5.35041 1.68084C5.52623 1.85665 5.625 2.09511 5.625 2.34375V4.6875C5.625 4.93614 5.52623 5.1746 5.35041 5.35041C5.1746 5.52623 4.93614 5.625 4.6875 5.625H2.34375C2.09511 5.625 1.85665 5.52623 1.68084 5.35041C1.50502 5.1746 1.40625 4.93614 1.40625 4.6875V2.34375ZM2.8125 10.7812V12.1875H4.21875V10.7812H2.8125ZM2.34375 9.375C2.09511 9.375 1.85665 9.47377 1.68084 9.64959C1.50502 9.8254 1.40625 10.0639 1.40625 10.3125V12.6562C1.40625 12.9049 1.50502 13.1433 1.68084 13.3192C1.85665 13.495 2.09511 13.5938 2.34375 13.5938H4.6875C4.93614 13.5938 5.1746 13.495 5.35041 13.3192C5.52623 13.1433 5.625 12.9049 5.625 12.6562V10.3125C5.625 10.0639 5.52623 9.8254 5.35041 9.64959C5.1746 9.47377 4.93614 9.375 4.6875 9.375H2.34375ZM10.7812 4.21875V2.8125H12.1875V4.21875H10.7812ZM9.375 2.34375C9.375 2.09511 9.47377 1.85665 9.64959 1.68084C9.8254 1.50502 10.0639 1.40625 10.3125 1.40625H12.6562C12.9049 1.40625 13.1433 1.50502 13.3192 1.68084C13.495 1.85665 13.5938 2.09511 13.5938 2.34375V4.6875C13.5938 4.93614 13.495 5.1746 13.3192 5.35041C13.1433 5.52623 12.9049 5.625 12.6562 5.625H10.3125C10.0639 5.625 9.8254 5.52623 9.64959 5.35041C9.47377 5.1746 9.375 4.93614 9.375 4.6875V2.34375Z" fill="currentColor"></path>', fill: true);
qr(15, '<path fill-rule="evenodd" clip-rule="evenodd" d="M8.20312 2.10938C8.20312 1.92289 8.12905 1.74405 7.99718 1.61219C7.86532 1.48033 7.68648 1.40625 7.5 1.40625C7.31352 1.40625 7.13468 1.48033 7.00282 1.61219C6.87095 1.74405 6.79688 1.92289 6.79688 2.10938V6.79688H2.10938C1.92289 6.79688 1.74405 6.87095 1.61219 7.00282C1.48033 7.13468 1.40625 7.31352 1.40625 7.5C1.40625 7.68648 1.48033 7.86532 1.61219 7.99718C1.74405 8.12905 1.92289 8.20312 2.10938 8.20312H7.26562C7.51427 8.20312 7.75272 8.10435 7.92854 7.92854C8.10435 7.75272 8.20312 7.51427 8.20312 7.26562V2.10938ZM6.79688 10.3125C6.79688 10.0639 6.89565 9.8254 7.07146 9.64959C7.24728 9.47377 7.48573 9.375 7.73438 9.375H10.3125C10.499 9.375 10.6778 9.44908 10.8097 9.58094C10.9415 9.7128 11.0156 9.89164 11.0156 10.0781C11.0156 10.2646 10.9415 10.4434 10.8097 10.5753C10.6778 10.7072 10.499 10.7812 10.3125 10.7812H8.20312V12.8906C8.20312 13.0771 8.12905 13.2559 7.99718 13.3878C7.86532 13.5197 7.68648 13.5938 7.5 13.5938C7.31352 13.5938 7.13468 13.5197 7.00282 13.3878C6.87095 13.2559 6.79688 13.0771 6.79688 12.8906V10.3125ZM12.1875 12.1875H10.0781C9.89164 12.1875 9.7128 12.2616 9.58094 12.3934C9.44908 12.5253 9.375 12.7041 9.375 12.8906C9.375 13.0771 9.44908 13.2559 9.58094 13.3878C9.7128 13.5197 9.89164 13.5938 10.0781 13.5938H12.6562C12.9049 13.5938 13.1433 13.495 13.3192 13.3192C13.495 13.1433 13.5938 12.9049 13.5938 12.6562V7.73438C13.5938 7.48573 13.495 7.24728 13.3192 7.07146C13.1433 6.89565 12.9049 6.79688 12.6562 6.79688H10.0781C9.89164 6.79688 9.7128 6.87095 9.58094 7.00282C9.44908 7.13468 9.375 7.31352 9.375 7.5C9.375 7.68648 9.44908 7.86532 9.58094 7.99718C9.7128 8.12905 9.89164 8.20312 10.0781 8.20312H12.1875V12.1875ZM2.8125 4.21875V2.8125H4.21875V4.21875H2.8125ZM1.40625 2.34375C1.40625 2.09511 1.50502 1.85665 1.68084 1.68084C1.85665 1.50502 2.09511 1.40625 2.34375 1.40625H4.6875C4.93614 1.40625 5.1746 1.50502 5.35041 1.68084C5.52623 1.85665 5.625 2.09511 5.625 2.34375V4.6875C5.625 4.93614 5.52623 5.1746 5.35041 5.35041C5.1746 5.52623 4.93614 5.625 4.6875 5.625H2.34375C2.09511 5.625 1.85665 5.52623 1.68084 5.35041C1.50502 5.1746 1.40625 4.93614 1.40625 4.6875V2.34375ZM2.8125 10.7812V12.1875H4.21875V10.7812H2.8125ZM2.34375 9.375C2.09511 9.375 1.85665 9.47377 1.68084 9.64959C1.50502 9.8254 1.40625 10.0639 1.40625 10.3125V12.6562C1.40625 12.9049 1.50502 13.1433 1.68084 13.3192C1.85665 13.495 2.09511 13.5938 2.34375 13.5938H4.6875C4.93614 13.5938 5.1746 13.495 5.35041 13.3192C5.52623 13.1433 5.625 12.9049 5.625 12.6562V10.3125C5.625 10.0639 5.52623 9.8254 5.35041 9.64959C5.1746 9.47377 4.93614 9.375 4.6875 9.375H2.34375ZM10.7812 4.21875V2.8125H12.1875V4.21875H10.7812ZM9.375 2.34375C9.375 2.09511 9.47377 1.85665 9.64959 1.68084C9.8254 1.50502 10.0639 1.40625 10.3125 1.40625H12.6562C12.9049 1.40625 13.1433 1.50502 13.3192 1.68084C13.495 1.85665 13.5938 2.09511 13.5938 2.34375V4.6875C13.5938 4.93614 13.495 5.1746 13.3192 5.35041C13.1433 5.52623 12.9049 5.625 12.6562 5.625H10.3125C10.0639 5.625 9.8254 5.52623 9.64959 5.35041C9.47377 5.1746 9.375 4.93614 9.375 4.6875V2.34375Z" fill="currentColor"></path>', fill: true),
/// 공유 다이어리 책갈피
diaryShared(24, '<path d="M1.54688 4.375C1.54688 2.71815 2.89002 1.375 4.54687 1.375H21.4521C22.0044 1.375 22.4521 1.82272 22.4521 2.375V21.6204C22.4521 22.1727 22.0044 22.6204 21.4521 22.6204H4.54687C2.89002 22.6204 1.54688 21.2772 1.54688 19.6204V4.375Z" stroke-width="2.4" stroke-linejoin="round"></path><path d="M22.2333 17.6562H4.77797C3.40986 17.6562 2.30078 18.7653 2.30078 20.1334C2.30078 21.5016 3.40986 22.6106 4.77797 22.6106H20.2878" stroke-width="2.4"></path><path d="M15.3884 13.4138C14.6869 13.2083 14.1096 12.7552 13.6987 12.1573C13.5853 11.9932 13.3775 11.59 13.126 11.7234C12.8064 11.8935 12.9819 12.3957 13.0877 12.6387C13.2617 13.0397 13.5531 13.423 13.9119 13.7174C14.3189 14.0524 14.8157 14.274 15.3294 14.2793C15.5923 14.2824 15.7916 14.0654 15.7893 13.8094C15.7878 13.561 15.5961 13.4751 15.3884 13.4138ZM12.3763 9.36135C12.2927 9.16049 12.2352 8.90674 12.0796 8.74651C11.9523 8.61618 11.2946 8.77181 11.1259 8.79634C10.393 8.90137 9.65396 9.19729 9.12651 9.73164C8.69336 10.1694 8.44037 10.9245 8.72249 11.5056C8.81142 11.6896 8.95248 11.8422 9.12268 11.9541C9.3979 12.135 9.72755 12.0239 10.0104 12.1496C10.1944 12.2309 10.2634 12.4432 10.4267 12.5513C10.6659 12.7108 10.9542 12.7844 11.2409 12.7729C11.2739 12.7721 11.3076 12.7698 11.3398 12.766C11.8358 12.7154 12.4 12.2807 12.6132 11.8453C12.8562 11.3477 12.8248 10.7559 12.6875 10.233C12.6086 9.9325 12.4959 9.64731 12.3763 9.36135ZM16.2447 8.9857C15.8668 7.94614 14.5888 7.90321 13.6665 8.06114C13.3691 8.11174 12.9628 8.1976 12.7082 8.36319C12.6753 8.38466 12.6239 8.41916 12.6017 8.45059C12.5756 8.48585 12.5771 8.50962 12.5894 8.53568C12.7788 8.95197 12.9382 9.38205 13.0709 9.8198C13.1958 10.2353 13.2602 10.6876 13.4603 11.0763C13.7355 11.6137 14.1211 11.8138 14.7099 11.879C14.9261 11.9027 15.1362 11.8184 15.3033 11.6927C15.5479 11.5102 15.7518 11.2887 15.8269 10.9843C15.8599 10.8494 15.8606 10.7214 15.8606 10.5795C15.8606 10.4208 15.7755 10.2476 15.945 10.1456C16.3774 9.8865 16.3996 9.41195 16.2447 8.9857ZM11.3084 8.35936C11.4632 8.31566 11.6211 8.2896 11.7775 8.26813C11.9585 8.24283 11.7936 7.76598 11.7683 7.68165C11.4946 6.76399 11.1313 5.6033 10.0572 5.38175C9.59799 5.28668 8.89729 5.49367 8.70793 5.95902C8.63816 6.13075 8.66116 6.30324 8.63433 6.4811C8.02792 6.71263 7.51734 7.40566 7.71513 8.05654C7.86693 8.55485 8.35298 8.81167 8.85819 8.83237C9.73139 8.86841 10.485 8.59088 11.3084 8.35936ZM12.3947 8.05194C12.6852 8.03277 12.9735 7.87944 13.251 7.80048C13.8674 7.62339 14.5551 7.63949 15.1147 7.29297C15.2581 7.20481 15.3378 7.03768 15.3984 6.88512C15.5486 6.51023 15.5839 6.03339 15.3378 5.6907C15.0572 5.30125 14.3987 5.18932 13.9525 5.29665C13.8674 5.31735 13.8053 5.31045 13.7716 5.22459C13.7018 5.05286 13.596 4.9049 13.4664 4.7899C13.1989 4.55378 12.8301 4.45258 12.4621 4.55608C12.1685 4.63811 11.9393 4.85507 11.8113 5.12722C11.4264 5.94752 11.8504 7.12124 12.2222 7.88328C12.2575 7.95611 12.308 8.05807 12.3947 8.05194Z" stroke="none" fill="currentColor"></path>', stroke: 2),
/// 개인 다이어리 책갈피 (자물쇠)
diaryPrivate(24, '<path d="M1.54688 4.375C1.54688 2.71815 2.89002 1.375 4.54687 1.375H21.4521C22.0044 1.375 22.4521 1.82272 22.4521 2.375V21.6204C22.4521 22.1727 22.0044 22.6204 21.4521 22.6204H4.54687C2.89002 22.6204 1.54688 21.2772 1.54688 19.6204V4.375Z" stroke-width="2.4" stroke-linejoin="round"></path><path d="M22.2333 17.6562H4.77797C3.40986 17.6562 2.30078 18.7653 2.30078 20.1334C2.30078 21.5016 3.40986 22.6106 4.77797 22.6106H20.2878" stroke-width="2.4"></path><path d="M15.1953 7.42019H14.7073V6.93219C14.7073 5.58677 13.6127 4.49219 12.2673 4.49219C10.9219 4.49219 9.82728 5.58677 9.82728 6.93219V7.42019H9.33928C9.08043 7.42019 8.83218 7.52302 8.64914 7.70605C8.46611 7.88909 8.36328 8.13734 8.36328 8.39619V13.2762C8.36328 13.535 8.46611 13.7833 8.64914 13.9663C8.83218 14.1494 9.08043 14.2522 9.33928 14.2522H15.1953C15.4541 14.2522 15.7024 14.1494 15.8854 13.9663C16.0685 13.7833 16.1713 13.535 16.1713 13.2762V8.39619C16.1713 8.13734 16.0685 7.88909 15.8854 7.70605C15.7024 7.52302 15.4541 7.42019 15.1953 7.42019ZM10.8033 6.93219C10.8033 6.12504 11.4601 5.46819 12.2673 5.46819C13.0744 5.46819 13.7313 6.12504 13.7313 6.93219V7.42019H10.8033V6.93219ZM12.7553 11.189V12.3002H11.7793V11.189C11.4889 11.0202 11.2913 10.7088 11.2913 10.3482C11.2913 10.0893 11.3941 9.84109 11.5771 9.65805C11.7602 9.47502 12.0084 9.37219 12.2673 9.37219C12.5261 9.37219 12.7744 9.47502 12.9574 9.65805C13.1405 9.84109 13.2433 10.0893 13.2433 10.3482C13.2433 10.7083 13.0456 11.0197 12.7553 11.189Z" stroke="none" fill="currentColor"></path>', stroke: 2);
const RupGlyph(this.viewBox, this.paths, {this.fill = false, this.stroke});