feat(flutter): 다이어리를 서버에 저장 (ApiDiaryRepository)

다이어리 저장소도 "장부 전체 저장" 에서 건별 계약(다이어리·기록·댓글 만들기·고치기·지우기)으로 바꿨다.

- 서버 저장소: 다이어리·기록·댓글 목록을 합쳐 DiaryBook 으로 만들고, 표지·기록 사진은 저장할 때 올린다
- 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"로 담는다 — 하루 한 번 쓰기 판단이 그대로 맞는다.
  로그인할 때 회원 id 를 기기에 둔다
- 책장이 가득 찬 경우(DIARY_001)는 null 로 받아 화면이 직접 안내하고 전역 알림은 끈다
- 아이 삭제 때의 다이어리 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 다이어리 화면은 실패하면 쓴 기록·댓글을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:28:20 +09:00
parent 60efc65ee0
commit a09086daf7
10 changed files with 546 additions and 45 deletions
@@ -10,6 +10,9 @@ import 'package:tma_app/features/auth/domain/social_provider.dart';
const String _kProviderKey = 'auth.provider';
const String _kNicknameKey = 'auth.nickname';
/// 로그인한 회원 id — 서버 기록의 작성자가 "나"인지 가릴 때 쓴다 (다이어리 기록·댓글)
const String kAuthUserIdKey = 'auth.userId';
/// 서버 인증 경로 (backend AuthController)
const String _kDevSessionsPath = '/auth/dev-sessions';
const String _kRevocationPath = '/auth/token-revocation';
@@ -56,6 +59,7 @@ class ApiAuthRepository implements AuthRepository {
final data = response.data!;
final user = data['user']! as Map<String, dynamic>;
await _prefs.setString(kAuthUserIdKey, user['id']! as String);
await _prefs.setString(_kProviderKey, (user['provider'] as String?) ?? provider.name);
final name = user['nickname'] as String?;
name == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, name);
@@ -89,6 +93,7 @@ class ApiAuthRepository implements AuthRepository {
}
Future<void> _forget() async {
await _prefs.remove(kAuthUserIdKey);
await _prefs.remove(_kProviderKey);
await _prefs.remove(_kNicknameKey);
await _session.clear();
@@ -0,0 +1,184 @@
import 'dart:ui' show Color;
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_exception.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/dio_client.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.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';
/// 다이어리 경로 (backend DiariesController · DiaryPostsController · DiaryCommentsController)
const String _kDiariesPath = '/diaries';
const String _kPostsPath = '/diary-posts';
const String _kCommentsPath = '/diary-comments';
/// 책장이 가득 찼을 때 서버가 주는 코드 — 앱은 null 로 받아 화면이 직접 안내한다
const String kDiaryFullCode = 'DIARY_001';
Diary diaryFromApi(Map<String, Object?> json) => Diary(
id: json['id']! as String,
name: json['name']! as String,
targetIds: (json['targetIds'] as List?)?.whereType<String>().toList() ?? const [],
ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first,
tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first,
coverPath: serverFileRefOrNull(json['coverFileId'] as String?),
mutedNotices: {
for (final n in (json['mutedNotices'] as List?)?.whereType<String>() ?? const <String>[])
?DiaryNotice.values.where((v) => v.name == n).firstOrNull,
},
);
/// 서버 저장소 — 사진은 저장할 때 올린다.
/// 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"(kMeAuthorId)로 바꿔 담는다 (하루 한 번 쓰기를 가린다).
class ApiDiaryRepository implements DiaryRepository {
ApiDiaryRepository({
required Dio dio,
required SessionStore session,
required FileUploader uploader,
required String? Function() myUserId,
}) : _dio = dio,
_session = session,
_uploader = uploader,
_myUserId = myUserId;
final Dio _dio;
final SessionStore _session;
final FileUploader _uploader;
final String? Function() _myUserId;
/// 지금은 다이어리 주인만 쓰므로, 내 id 를 모르면(이전 버전에서 로그인) 모두 나로 본다
String _author(Object? id) {
final me = _myUserId();
return me == null || id == me ? kMeAuthorId : id! as String;
}
DiaryPost _post(Map<String, Object?> json) => DiaryPost(
id: json['id']! as String,
diaryId: json['diaryId']! as String,
date: parseIsoDate(json['date']! as String),
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
text: json['text'] as String? ?? '',
mood: Mood.byName(json['mood'] as String?),
photoPath: serverFileRefOrNull(json['photoFileId'] as String?),
authorId: _author(json['authorId']),
);
DiaryComment _comment(Map<String, Object?> json) => DiaryComment(
id: json['id']! as String,
postId: json['postId']! as String,
text: json['text'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
authorId: _author(json['authorId']),
);
@override
Future<DiaryBook> fetch() async {
if (!_session.hasSession) return DiaryBook.empty;
return DiaryBook(
diaries: await fetchAllPages(_dio, _kDiariesPath, diaryFromApi),
posts: await fetchAllPages(_dio, _kPostsPath, _post),
comments: await fetchAllPages(_dio, _kCommentsPath, _comment),
);
}
@override
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
}) async {
final coverFileId = await _uploader.ensureUploaded(coverPath);
try {
final response = await _dio.post<Map<String, dynamic>>(
_kDiariesPath,
data: {
'name': name,
'targetIds': targetIds,
'ribbon': hexOf(ribbon),
'tint': hexOf(tint),
'coverFileId': coverFileId,
},
// 가득 찬 경우는 화면이 "최대 6개" 로 직접 안내한다
options: Options(extra: {
kSilentCodesExtra: [kDiaryFullCode],
}),
);
return diaryFromApi(response.data!);
} on DioException catch (e) {
final error = e.error;
if (error is ApiException && error.code == kDiaryFullCode) return null;
rethrow;
}
}
@override
Future<Diary> updateDiary(Diary diary) async {
final response = await _dio.patch<Map<String, dynamic>>(
'$_kDiariesPath/${diary.id}',
data: {
'name': diary.name,
'targetIds': diary.targetIds,
'ribbon': hexOf(diary.ribbon),
'coverFileId': await _uploader.ensureUploaded(diary.coverPath),
'mutedNotices': [for (final n in diary.mutedNotices) n.name],
},
);
return diaryFromApi(response.data!);
}
@override
Future<void> deleteDiary(String diaryId) => _dio.delete<void>('$_kDiariesPath/$diaryId');
@override
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
}) async {
final photoFileId = await _uploader.ensureUploaded(photoPath);
final response = await _dio.post<Map<String, dynamic>>(
'$_kDiariesPath/$diaryId/posts',
data: {'date': toIsoDate(date), 'text': text, 'mood': mood?.name, 'photoFileId': photoFileId},
);
return _post(response.data!);
}
@override
Future<DiaryPost> updatePostText(DiaryPost post, String text) async {
final response = await _dio.patch<Map<String, dynamic>>('$_kPostsPath/${post.id}', data: {'text': text});
return _post(response.data!);
}
@override
Future<void> deletePost(String postId) => _dio.delete<void>('$_kPostsPath/$postId');
@override
Future<DiaryComment> addComment(String postId, String text) async {
final response = await _dio.post<Map<String, dynamic>>('$_kPostsPath/$postId/comments', data: {'text': text});
return _comment(response.data!);
}
@override
Future<DiaryComment> updateComment(DiaryComment comment, String text) async {
final response = await _dio.patch<Map<String, dynamic>>('$_kCommentsPath/${comment.id}', data: {'text': text});
return _comment(response.data!);
}
@override
Future<void> deleteComment(String commentId) => _dio.delete<void>('$_kCommentsPath/$commentId');
/// 서버가 아이를 지우면서 이미 다이어리 대상에서 뗐다
@override
Future<void> dropTarget(String targetId) async {}
}
@@ -1,16 +1,62 @@
import 'dart:convert';
import 'dart:ui' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
import 'package:tma_app/features/diary/data/api_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';
/// 다이어리 저장소 계약 — 공유 다이어리(서버)가 생기면 API 구현으로 바꾼다.
/// 다이어리 저장소 계약 — 서버(ApiDiaryRepository) / 기기(LocalDiaryRepository).
/// 바꾼 결과(id 가 붙은 다이어리·기록·댓글)를 돌려주면 알림기가 [DiaryBook] 에 반영한다.
abstract interface class DiaryRepository {
Future<DiaryBook> fetch();
Future<void> save(DiaryBook book);
/// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001)
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
});
/// 이름·대상·리본·표지·알림을 바꾼다 (바탕색은 만들 때 정한 값을 유지한다)
Future<Diary> updateDiary(Diary diary);
/// 다이어리와 그 안의 기록·댓글을 모두 지운다
Future<void> deleteDiary(String diaryId);
/// 기록 남기기 — [date] 는 오늘(기기 날짜). 하루 한 번
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
});
Future<DiaryPost> updatePostText(DiaryPost post, String text);
/// 기록과 그 댓글을 지운다
Future<void> deletePost(String postId);
Future<DiaryComment> addComment(String postId, String text);
Future<DiaryComment> updateComment(DiaryComment comment, String text);
Future<void> deleteComment(String commentId);
/// 아이를 지우면 다이어리 대상에서만 뗀다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 이미 정리했다.
Future<void> dropTarget(String targetId);
}
const String _kDiariesKey = 'diary.diaries.v1';
@@ -20,27 +66,120 @@ 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 으로 저장한다.
/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트).
class LocalDiaryRepository implements DiaryRepository {
LocalDiaryRepository(this._prefs);
LocalDiaryRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final SharedPreferences _prefs;
final DateTime Function() _clock;
int _serial = 0;
@override
Future<DiaryBook> fetch() async => DiaryBook(
String _newId(String prefix) => '$prefix${_clock().microsecondsSinceEpoch}_${_serial++}';
DiaryBook _read() => 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 {
Future<void> _write(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()]));
}
@override
Future<DiaryBook> fetch() async => _read();
/// 가득 찼는지는 알림기가 아이의 보관 여부까지 보고 먼저 확인한다
@override
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
}) async {
final diary = Diary(
id: _newId('d'),
name: name,
targetIds: targetIds,
ribbon: ribbon,
tint: tint,
coverPath: coverPath,
);
await _write(_read().addDiary(diary));
return diary;
}
@override
Future<Diary> updateDiary(Diary diary) async {
await _write(_read().updateDiary(diary));
return diary;
}
@override
Future<void> deleteDiary(String diaryId) async => _write(_read().deleteDiary(diaryId));
@override
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
}) async {
final post = DiaryPost(
id: _newId('p'),
diaryId: diaryId,
date: date,
createdAt: _clock(),
text: text,
mood: mood,
photoPath: photoPath,
);
await _write(_read().addPost(post));
return post;
}
@override
Future<DiaryPost> updatePostText(DiaryPost post, String text) async {
await _write(_read().updatePostText(post.id, text));
return post.copyWith(text: text);
}
@override
Future<void> deletePost(String postId) async => _write(_read().deletePost(postId));
@override
Future<DiaryComment> addComment(String postId, String text) async {
final comment = DiaryComment(id: _newId('c'), postId: postId, text: text, createdAt: _clock());
await _write(_read().addComment(comment));
return comment;
}
@override
Future<DiaryComment> updateComment(DiaryComment comment, String text) async {
await _write(_read().updateComment(comment.id, text));
return comment.copyWith(text: text);
}
@override
Future<void> deleteComment(String commentId) async => _write(_read().deleteComment(commentId));
@override
Future<void> dropTarget(String targetId) async => _write(_read().dropTarget(targetId));
}
final diaryRepositoryProvider = Provider<DiaryRepository>((ref) {
return LocalDiaryRepository(ref.watch(sharedPreferencesProvider));
final prefs = ref.watch(sharedPreferencesProvider);
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalDiaryRepository(prefs),
DataMode.api => ApiDiaryRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
uploader: ref.watch(fileUploaderProvider),
myUserId: () => prefs.getString(kAuthUserIdKey),
),
};
});
@@ -3,6 +3,7 @@ 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/api/api_guard.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';
@@ -70,6 +71,9 @@ class _DiaryComposePageState extends ConsumerState<DiaryComposePage> {
toast.show('기록을 남겼어요');
// 남겼음을 알려 다이어리가 피드 보기로 돌아가게 한다
if (mounted) context.pop(true);
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 쓴 기록을 그대로 두고 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -3,6 +3,7 @@ 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/api/api_guard.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';
@@ -48,7 +49,7 @@ class DiarySettingsPage extends ConsumerWidget {
// 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
context.go(AppRoutes.records);
await ref.read(diaryProvider.notifier).deleteDiary(diary.id);
if (!await succeeded(ref.read(diaryProvider.notifier).deleteDiary(diary.id))) return;
ref.read(toastProvider.notifier).show('다이어리를 제거했어요');
}
@@ -144,13 +145,13 @@ class DiarySettingsPage extends ConsumerWidget {
HeartToggle(
value: diary.notifies(row.notice),
label: row.title,
onChanged: (on) => unawaited(ref.read(diaryProvider.notifier).updateDiary(
onChanged: (on) => unawaited(succeeded(ref.read(diaryProvider.notifier).updateDiary(
diary.copyWith(
mutedNotices: on
? ({...diary.mutedNotices}..remove(row.notice))
: {...diary.mutedNotices, row.notice},
),
)),
))),
),
],
),
@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.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';
@@ -10,19 +11,20 @@ import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
/// 다이어리 · 기록 · 댓글
/// 저장은 저장소가 건별로 하고, 돌려받은 결과를 [DiaryBook] 에 반영한다.
class DiaryNotifier extends AsyncNotifier<DiaryBook> {
DiaryRepository get _repo => ref.read(diaryRepositoryProvider);
int _serial = 0;
@override
Future<DiaryBook> build() => ref.watch(diaryRepositoryProvider).fetch();
Future<DiaryBook> build() {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
return 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);
void _set(DiaryBook next) {
if (ref.mounted) state = AsyncData(next);
}
@@ -34,49 +36,67 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
String? coverPath,
}) async {
if (_book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {})) return null;
final diary = Diary(
id: _newId('d'),
final diary = await _repo.createDiary(
name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(),
targetIds: targetIds,
ribbon: ribbon,
tint: kDiaryTints[Random().nextInt(kDiaryTints.length)],
coverPath: coverPath,
);
await _commit(_book.addDiary(diary));
if (diary != null) _set(_book.addDiary(diary));
return diary;
}
Future<void> updateDiary(Diary diary) => _commit(_book.updateDiary(diary));
Future<void> updateDiary(Diary diary) async => _set(_book.updateDiary(await _repo.updateDiary(diary)));
Future<void> deleteDiary(String id) => _commit(_book.deleteDiary(id));
Future<void> deleteDiary(String id) async {
await _repo.deleteDiary(id);
_set(_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'),
Future<void> addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) async {
final post = await _repo.addPost(
diaryId: diaryId,
date: dateOnly(now),
createdAt: now,
date: dateOnly(DateTime.now()),
text: text,
mood: mood,
photoPath: photoPath,
)));
);
_set(_book.addPost(post));
}
Future<void> updatePostText(String id, String text) => _commit(_book.updatePostText(id, text));
Future<void> updatePostText(String id, String text) async {
final post = _book.posts.where((p) => p.id == id).firstOrNull;
if (post == null) return;
final saved = await _repo.updatePostText(post, text);
_set(_book.updatePostText(id, saved.text));
}
Future<void> deletePost(String id) => _commit(_book.deletePost(id));
Future<void> deletePost(String id) async {
await _repo.deletePost(id);
_set(_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> addComment(String postId, String text) async =>
_set(_book.addComment(await _repo.addComment(postId, text)));
Future<void> updateComment(String id, String text) => _commit(_book.updateComment(id, text));
Future<void> updateComment(String id, String text) async {
final comment = _book.comments.where((c) => c.id == id).firstOrNull;
if (comment == null) return;
final saved = await _repo.updateComment(comment, text);
_set(_book.updateComment(id, saved.text));
}
Future<void> deleteComment(String id) => _commit(_book.deleteComment(id));
Future<void> deleteComment(String id) async {
await _repo.deleteComment(id);
_set(_book.deleteComment(id));
}
/// 아이를 지우면 다이어리 대상에서만 뗀다
Future<void> dropTarget(String targetId) => _commit(_book.dropTarget(targetId));
/// 아이를 지우면 다이어리 대상에서만 뗀다 (서버 모드는 서버가 이미 정리했다)
Future<void> dropTarget(String targetId) async {
await _repo.dropTarget(targetId);
_set(_book.dropTarget(targetId));
}
}
final diaryProvider = AsyncNotifierProvider<DiaryNotifier, DiaryBook>(DiaryNotifier.new);
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.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';
@@ -294,8 +295,9 @@ class _CommentsState extends ConsumerState<_Comments> {
Future<void> _add() async {
final text = _draft.text.trim();
if (text.isEmpty) return;
_draft.clear();
await ref.read(diaryProvider.notifier).addComment(widget.postId, text);
// 저장하지 못하면 쓴 댓글을 그대로 둔다 (실패 알림은 인터셉터가 띄웠다)
if (!await succeeded(ref.read(diaryProvider.notifier).addComment(widget.postId, text))) return;
if (mounted && _draft.text.trim() == text) _draft.clear();
}
Future<void> _saveEdit() async {
@@ -303,7 +305,7 @@ class _CommentsState extends ConsumerState<_Comments> {
final text = _edit.text.trim();
setState(() => _editingId = null);
if (id == null || text.isEmpty) return;
await ref.read(diaryProvider.notifier).updateComment(id, text);
await succeeded(ref.read(diaryProvider.notifier).updateComment(id, text));
}
Future<void> _delete(DiaryComment c) async {
@@ -315,7 +317,7 @@ class _CommentsState extends ConsumerState<_Comments> {
layout: RupConfirmLayout.row,
tightTop: true,
);
if (ok) await ref.read(diaryProvider.notifier).deleteComment(c.id);
if (ok) await succeeded(ref.read(diaryProvider.notifier).deleteComment(c.id));
}
@override
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.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';
@@ -216,6 +217,9 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
toast.show('다이어리 정보를 수정했어요');
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 시트를 열어 둔 채 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.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';
@@ -17,7 +18,7 @@ Future<void> managePost(BuildContext context, WidgetRef ref, DiaryPost post) asy
case PostAction.edit:
final text = await showEditPostSheet(context, post.text);
if (text == null) return;
await notifier.updatePostText(post.id, text);
if (!await succeeded(notifier.updatePostText(post.id, text))) return;
toast.show('기록을 수정했어요');
case PostAction.delete:
final ok = await showRupConfirm(
@@ -30,7 +31,7 @@ Future<void> managePost(BuildContext context, WidgetRef ref, DiaryPost post) asy
iconColor: kDangerTone,
);
if (!ok) return;
await notifier.deletePost(post.id);
if (!await succeeded(notifier.deletePost(post.id))) return;
toast.show('기록을 삭제했어요');
}
}
+141
View File
@@ -0,0 +1,141 @@
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/features/diary/data/api_diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'support/fake_api.dart';
Map<String, Object?> _diaryJson({String id = 'd1', String? coverFileId}) => {
'id': id,
'name': '콩이의 하루',
'targetIds': ['p1'],
'ribbon': '#FF7500',
'tint': '#FF8A5B',
'coverFileId': coverFileId,
'mutedNotices': ['reminder'],
'createdAt': '2026-10-11T00:00:00.000Z',
};
Map<String, Object?> _postJson({String id = 'post-1', String authorId = 'me-uuid', String date = '2026-10-11'}) => {
'id': id,
'diaryId': 'd1',
'authorId': authorId,
'date': date,
'createdAt': '2026-10-11T03:00:00.000Z',
'text': '산책했어요',
'mood': 'happy',
'photoFileId': 'f1',
};
void main() {
late FakeApi api;
late ApiDiaryRepository repo;
String? myId = 'me-uuid';
setUp(() {
api = FakeApi();
myId = 'me-uuid';
repo = ApiDiaryRepository(
dio: api.dio,
session: api.session,
uploader: FileUploader(api.dio, api.reported.add),
myUserId: () => myId,
);
});
test('다이어리·기록·댓글을 불러와 하나의 장부로 합친다 — 내가 쓴 기록은 "나"로 담는다', () async {
api.on('GET', '/diaries', (r) => ok(page([_diaryJson(coverFileId: 'cover-1')])));
api.on('GET', '/diary-posts', (r) => ok(page([_postJson(), _postJson(id: 'post-2', authorId: 'friend-uuid')])));
api.on('GET', '/diary-comments', (r) => ok(page([
{'id': 'c1', 'postId': 'post-1', 'authorId': 'me-uuid', 'text': '귀여워', 'createdAt': '2026-10-11T04:00:00.000Z'},
])));
final book = await repo.fetch();
final diary = book.diaries.single;
expect(diary.coverPath, 'server-file:cover-1');
expect(diary.mutedNotices, {DiaryNotice.reminder});
expect(diary.ribbon, const Color(0xFFFF7500));
final mine = book.posts.firstWhere((p) => p.id == 'post-1');
expect(mine.authorId, kMeAuthorId);
expect(mine.mood, Mood.happy);
expect(mine.photoPath, 'server-file:f1');
expect(book.posts.firstWhere((p) => p.id == 'post-2').authorId, 'friend-uuid');
expect(book.comments.single.authorId, kMeAuthorId);
// 하루 한 번 쓰기 — 내가 오늘 쓴 기록을 알아본다
expect(book.wroteOn('d1', DateTime(2026, 10, 11)), isTrue);
});
test('책장이 가득 차면(DIARY_001) null 로 받고 전역 알림은 띄우지 않는다', () async {
api.on('POST', '/diaries', (r) => failure('DIARY_001', 409));
final created = await repo.createDiary(
name: '새 다이어리',
targetIds: const [],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
);
expect(created, isNull);
expect(api.reported, isEmpty);
});
test('기록 남기기는 기기의 오늘 날짜와 기분·사진을 보낸다', () async {
api.on('POST', '/diaries/:id/posts', (r) {
expect(r.params['id'], 'd1');
expect(r.json, {'date': '2026-10-11', 'text': '산책했어요', 'mood': 'happy', 'photoFileId': 'f1'});
return ok(_postJson(), status: 201);
});
final post = await repo.addPost(
diaryId: 'd1',
date: DateTime(2026, 10, 11),
text: '산책했어요',
mood: Mood.happy,
photoPath: 'server-file:f1',
);
expect(post.authorId, kMeAuthorId);
expect(api.sent('POST', '/files'), isEmpty);
});
test('오늘 이미 기록했으면(DIARY_002) 서버 문구 대신 사전 문구로 알린다', () async {
api.on('POST', '/diaries/:id/posts', (r) => failure('DIARY_002', 409));
await expectLater(
repo.addPost(diaryId: 'd1', date: DateTime(2026, 10, 11), text: '두 번째'),
throwsA(anything),
);
expect(api.reported.single.code, 'DIARY_002');
expect(api.reported.single.message, isNot('서버 문구'));
});
test('다이어리 수정은 바탕색을 보내지 않는다 (만들 때 정한 값을 유지한다)', () async {
api.on('PATCH', '/diaries/:id', (r) {
expect(r.json!.containsKey('tint'), isFalse);
expect(r.json!['mutedNotices'], ['newComment']);
return ok(_diaryJson());
});
final diary = Diary(
id: 'd1',
name: '콩이의 하루',
targetIds: const ['p1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
mutedNotices: const {DiaryNotice.newComment},
);
await repo.updateDiary(diary);
expect(api.sent('PATCH', '/diaries/d1'), hasLength(1));
});
test('아이를 지울 때 다이어리 정리는 서버가 이미 했다', () async {
await repo.dropTarget('p1');
expect(api.requests, isEmpty);
});
}