From a09086daf7f0cde84557eb682ef2400b5bdcf74f Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 18:28:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=84=9C=EB=B2=84=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20(ApiDiaryRepository)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다이어리 저장소도 "장부 전체 저장" 에서 건별 계약(다이어리·기록·댓글 만들기·고치기·지우기)으로 바꿨다. - 서버 저장소: 다이어리·기록·댓글 목록을 합쳐 DiaryBook 으로 만들고, 표지·기록 사진은 저장할 때 올린다 - 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"로 담는다 — 하루 한 번 쓰기 판단이 그대로 맞는다. 로그인할 때 회원 id 를 기기에 둔다 - 책장이 가득 찬 경우(DIARY_001)는 null 로 받아 화면이 직접 안내하고 전역 알림은 끈다 - 아이 삭제 때의 다이어리 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다 - 다이어리 화면은 실패하면 쓴 기록·댓글을 그대로 둔다 (알림은 인터셉터) Co-Authored-By: Claude Opus 5.5 --- .../repositories/api_auth_repository.dart | 5 + .../diary/data/api_diary_repository.dart | 184 ++++++++++++++++++ .../features/diary/data/diary_repository.dart | 157 ++++++++++++++- .../pages/diary_compose_page.dart | 4 + .../pages/diary_settings_page.dart | 7 +- .../providers/diary_providers.dart | 74 ++++--- .../presentation/widgets/diary_post_card.dart | 10 +- .../presentation/widgets/diary_sheets.dart | 4 + .../presentation/widgets/post_actions.dart | 5 +- flutter/test/api_diary_repository_test.dart | 141 ++++++++++++++ 10 files changed, 546 insertions(+), 45 deletions(-) create mode 100644 flutter/lib/features/diary/data/api_diary_repository.dart create mode 100644 flutter/test/api_diary_repository_test.dart diff --git a/flutter/lib/features/auth/data/repositories/api_auth_repository.dart b/flutter/lib/features/auth/data/repositories/api_auth_repository.dart index 946394b..6bce6b6 100644 --- a/flutter/lib/features/auth/data/repositories/api_auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/api_auth_repository.dart @@ -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; + 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 _forget() async { + await _prefs.remove(kAuthUserIdKey); await _prefs.remove(_kProviderKey); await _prefs.remove(_kNicknameKey); await _session.clear(); diff --git a/flutter/lib/features/diary/data/api_diary_repository.dart b/flutter/lib/features/diary/data/api_diary_repository.dart new file mode 100644 index 0000000..9c5af27 --- /dev/null +++ b/flutter/lib/features/diary/data/api_diary_repository.dart @@ -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 json) => Diary( + id: json['id']! as String, + name: json['name']! as String, + targetIds: (json['targetIds'] as List?)?.whereType().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() ?? const []) + ?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 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 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 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 createDiary({ + required String name, + required List targetIds, + required Color ribbon, + required Color tint, + String? coverPath, + }) async { + final coverFileId = await _uploader.ensureUploaded(coverPath); + try { + final response = await _dio.post>( + _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 updateDiary(Diary diary) async { + final response = await _dio.patch>( + '$_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 deleteDiary(String diaryId) => _dio.delete('$_kDiariesPath/$diaryId'); + + @override + Future 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>( + '$_kDiariesPath/$diaryId/posts', + data: {'date': toIsoDate(date), 'text': text, 'mood': mood?.name, 'photoFileId': photoFileId}, + ); + return _post(response.data!); + } + + @override + Future updatePostText(DiaryPost post, String text) async { + final response = await _dio.patch>('$_kPostsPath/${post.id}', data: {'text': text}); + return _post(response.data!); + } + + @override + Future deletePost(String postId) => _dio.delete('$_kPostsPath/$postId'); + + @override + Future addComment(String postId, String text) async { + final response = await _dio.post>('$_kPostsPath/$postId/comments', data: {'text': text}); + return _comment(response.data!); + } + + @override + Future updateComment(DiaryComment comment, String text) async { + final response = await _dio.patch>('$_kCommentsPath/${comment.id}', data: {'text': text}); + return _comment(response.data!); + } + + @override + Future deleteComment(String commentId) => _dio.delete('$_kCommentsPath/$commentId'); + + /// 서버가 아이를 지우면서 이미 다이어리 대상에서 뗐다 + @override + Future dropTarget(String targetId) async {} +} diff --git a/flutter/lib/features/diary/data/diary_repository.dart b/flutter/lib/features/diary/data/diary_repository.dart index 6a23a58..aee3ad9 100644 --- a/flutter/lib/features/diary/data/diary_repository.dart +++ b/flutter/lib/features/diary/data/diary_repository.dart @@ -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 fetch(); - Future save(DiaryBook book); + /// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001) + Future createDiary({ + required String name, + required List targetIds, + required Color ribbon, + required Color tint, + String? coverPath, + }); + + /// 이름·대상·리본·표지·알림을 바꾼다 (바탕색은 만들 때 정한 값을 유지한다) + Future updateDiary(Diary diary); + + /// 다이어리와 그 안의 기록·댓글을 모두 지운다 + Future deleteDiary(String diaryId); + + /// 기록 남기기 — [date] 는 오늘(기기 날짜). 하루 한 번 + Future addPost({ + required String diaryId, + required DateTime date, + required String text, + Mood? mood, + String? photoPath, + }); + + Future updatePostText(DiaryPost post, String text); + + /// 기록과 그 댓글을 지운다 + Future deletePost(String postId); + + Future addComment(String postId, String text); + + Future updateComment(DiaryComment comment, String text); + + Future deleteComment(String commentId); + + /// 아이를 지우면 다이어리 대상에서만 뗀다. + /// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 이미 정리했다. + Future dropTarget(String targetId); } const String _kDiariesKey = 'diary.diaries.v1'; @@ -20,27 +66,120 @@ const String _kCommentsKey = 'diary.comments.v1'; List> _decode(String? raw) => raw == null ? const [] : (jsonDecode(raw) as List).whereType>().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 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 save(DiaryBook book) async { + Future _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 fetch() async => _read(); + + /// 가득 찼는지는 알림기가 아이의 보관 여부까지 보고 먼저 확인한다 + @override + Future createDiary({ + required String name, + required List 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 updateDiary(Diary diary) async { + await _write(_read().updateDiary(diary)); + return diary; + } + + @override + Future deleteDiary(String diaryId) async => _write(_read().deleteDiary(diaryId)); + + @override + Future 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 updatePostText(DiaryPost post, String text) async { + await _write(_read().updatePostText(post.id, text)); + return post.copyWith(text: text); + } + + @override + Future deletePost(String postId) async => _write(_read().deletePost(postId)); + + @override + Future 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 updateComment(DiaryComment comment, String text) async { + await _write(_read().updateComment(comment.id, text)); + return comment.copyWith(text: text); + } + + @override + Future deleteComment(String commentId) async => _write(_read().deleteComment(commentId)); + + @override + Future dropTarget(String targetId) async => _write(_read().dropTarget(targetId)); } final diaryRepositoryProvider = Provider((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), + ), + }; }); diff --git a/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart b/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart index 64eaeb5..9ebbc93 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart @@ -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 { toast.show('기록을 남겼어요'); // 남겼음을 알려 다이어리가 피드 보기로 돌아가게 한다 if (mounted) context.pop(true); + } catch (e) { + // 실패 알림은 인터셉터가 띄웠다 — 쓴 기록을 그대로 두고 다시 시도할 수 있게 한다 + if (!isReportedApiFailure(e)) rethrow; } finally { if (mounted) setState(() => _saving = false); } diff --git a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart index f2e330f..e896e7a 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart @@ -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}, ), - )), + ))), ), ], ), diff --git a/flutter/lib/features/diary/presentation/providers/diary_providers.dart b/flutter/lib/features/diary/presentation/providers/diary_providers.dart index a5c61f7..fa03ea5 100644 --- a/flutter/lib/features/diary/presentation/providers/diary_providers.dart +++ b/flutter/lib/features/diary/presentation/providers/diary_providers.dart @@ -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 { DiaryRepository get _repo => ref.read(diaryRepositoryProvider); - int _serial = 0; @override - Future build() => ref.watch(diaryRepositoryProvider).fetch(); + Future 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 _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 { 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 updateDiary(Diary diary) => _commit(_book.updateDiary(diary)); + Future updateDiary(Diary diary) async => _set(_book.updateDiary(await _repo.updateDiary(diary))); - Future deleteDiary(String id) => _commit(_book.deleteDiary(id)); + Future deleteDiary(String id) async { + await _repo.deleteDiary(id); + _set(_book.deleteDiary(id)); + } - Future addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) { - final now = DateTime.now(); - return _commit(_book.addPost(DiaryPost( - id: _newId('p'), + Future 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 updatePostText(String id, String text) => _commit(_book.updatePostText(id, text)); + Future 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 deletePost(String id) => _commit(_book.deletePost(id)); + Future deletePost(String id) async { + await _repo.deletePost(id); + _set(_book.deletePost(id)); + } - Future addComment(String postId, String text) => _commit(_book.addComment( - DiaryComment(id: _newId('c'), postId: postId, text: text, createdAt: DateTime.now()), - )); + Future addComment(String postId, String text) async => + _set(_book.addComment(await _repo.addComment(postId, text))); - Future updateComment(String id, String text) => _commit(_book.updateComment(id, text)); + Future 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 deleteComment(String id) => _commit(_book.deleteComment(id)); + Future deleteComment(String id) async { + await _repo.deleteComment(id); + _set(_book.deleteComment(id)); + } - /// 아이를 지우면 다이어리 대상에서만 뗀다 - Future dropTarget(String targetId) => _commit(_book.dropTarget(targetId)); + /// 아이를 지우면 다이어리 대상에서만 뗀다 (서버 모드는 서버가 이미 정리했다) + Future dropTarget(String targetId) async { + await _repo.dropTarget(targetId); + _set(_book.dropTarget(targetId)); + } } final diaryProvider = AsyncNotifierProvider(DiaryNotifier.new); diff --git a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart index 9afdf23..ec74d4d 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart @@ -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 _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 _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 _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 diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart index 3039200..ffe6381 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart @@ -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); } diff --git a/flutter/lib/features/diary/presentation/widgets/post_actions.dart b/flutter/lib/features/diary/presentation/widgets/post_actions.dart index b2d206f..d2b0ef3 100644 --- a/flutter/lib/features/diary/presentation/widgets/post_actions.dart +++ b/flutter/lib/features/diary/presentation/widgets/post_actions.dart @@ -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 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 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('기록을 삭제했어요'); } } diff --git a/flutter/test/api_diary_repository_test.dart b/flutter/test/api_diary_repository_test.dart new file mode 100644 index 0000000..40b6ea1 --- /dev/null +++ b/flutter/test/api_diary_repository_test.dart @@ -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 _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 _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); + }); +}