generated from ttipo/project-base
feat(flutter): 다이어리를 서버에 저장 (ApiDiaryRepository)
다이어리 저장소도 "장부 전체 저장" 에서 건별 계약(다이어리·기록·댓글 만들기·고치기·지우기)으로 바꿨다. - 서버 저장소: 다이어리·기록·댓글 목록을 합쳐 DiaryBook 으로 만들고, 표지·기록 사진은 저장할 때 올린다 - 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"로 담는다 — 하루 한 번 쓰기 판단이 그대로 맞는다. 로그인할 때 회원 id 를 기기에 둔다 - 책장이 가득 찬 경우(DIARY_001)는 null 로 받아 화면이 직접 안내하고 전역 알림은 끈다 - 아이 삭제 때의 다이어리 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다 - 다이어리 화면은 실패하면 쓴 기록·댓글을 그대로 둔다 (알림은 인터셉터) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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('기록을 삭제했어요');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user