feat(flutter): 공유 다이어리 — 초대·참여·멤버와 함께 쓰기

다이어리를 가족·지인과 함께 쓰는 화면을 연다.
- 다이어리 설정: 개인↔공유 전환(멤버가 있으면 확인), 멤버 목록·내보내기, 초대 QR·코드 복사, 다이어리 나가기
- 참여: 코드 입력 시트 · 초대 QR 스캐너 · 참여 확인 (명함 스캐너를 공용 QR 스캐너로 바꿔 함께 쓴다)
- 만들 때 공유 다이어리를 고를 수 있다
- 다른 멤버의 기록·댓글은 그 사람 이름·사진으로, 수정·삭제는 내 것만
- 참여한 다이어리 표지는 서버가 준 주인의 아이들로 그린다
- 다른 멤버의 새 글·내 기록의 댓글을 알림으로 보여 주고, 공유 다이어리에 들어오면 다시 읽는다
- 알림 설정은 멤버마다 따로 저장한다
- 기기 저장 모드에서는 공유 다이어리를 쓸 수 없다고 안내한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 20:50:13 +09:00
parent 688e72bf04
commit f50ab85a75
27 changed files with 2787 additions and 678 deletions
+100 -1
View File
@@ -26,6 +26,7 @@ import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/domain/social_provider.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_invitation.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/data/repositories/api_pet_repository.dart';
@@ -192,7 +193,7 @@ void main() {
final comment = await diaries.addComment(post.id, '귀여워');
await diaries.updatePostText(post, '산책 다녀왔어요');
await diaries.updateComment(comment, '정말 귀여워');
await diaries.updateDiary(diary.copyWith(mutedNotices: {DiaryNotice.reminder}));
await diaries.setMutedNotices(diary.id, {DiaryNotice.reminder});
final diaryBook = await diaries.fetch();
expect(diaryBook.wroteOn(diary.id, day), isTrue);
@@ -233,6 +234,104 @@ void main() {
expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다');
});
test('공유 다이어리 — 초대 코드로 함께 쓰고, 멤버의 글·사진이 서로 보이며, 내보내면 코드가 바뀐다', () async {
// 두 번째 회원 — 회원 id 는 prefs 한 자리에 남으므로 각자 붙잡아 둔다
final ownerId = prefs.getString(kAuthUserIdKey)!;
final memberSession = SessionStore(MemoryTokenVault());
final memberDio = DioClient(baseUrl: baseUrl, session: memberSession, onError: reported.add).dio;
final memberUploader = FileUploader(memberDio, reported.add);
final memberAuth = ApiAuthRepository(
dio: memberDio,
session: memberSession,
prefs: prefs,
deviceKeys: FixedDeviceKeyStore('live-member-${DateTime.now().microsecondsSinceEpoch}'),
);
await memberAuth.signIn(SocialProvider.kakao);
final memberId = prefs.getString(kAuthUserIdKey)!;
await prefs.setString(kAuthUserIdKey, ownerId);
addTearDown(() async {
if (memberSession.hasSession) await memberAuth.deleteAccount();
await prefs.setString(kAuthUserIdKey, ownerId);
});
final owner = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => ownerId);
final member = ApiDiaryRepository(
dio: memberDio,
session: memberSession,
uploader: memberUploader,
myUserId: () => memberId,
);
final today = DateTime.now();
final day = DateTime(today.year, today.month, today.day);
// 주인 — 공유 다이어리를 만들면 초대 코드가 생긴다
final shared = await owner.createDiary(
name: '함께 쓰는 하루',
targetIds: const ['self'],
ribbon: kRibbonColors[3],
tint: kDiaryTints[1],
coverPath: photoPath,
shared: true,
);
expect(shared!.shared, isTrue);
expect(shared.isOwner, isTrue);
final code = shared.inviteCode!;
expect(parseInviteCode(code), code);
// 멤버 — 미리 보고 참여한다
final invitation = await member.previewInvitation(code);
expect(invitation.name, '함께 쓰는 하루');
expect(invitation.isMember, isFalse);
var memberBook = await member.join(code);
final joined = memberBook.diaries.single;
expect(joined.isOwner, isFalse);
expect(joined.inviteCode, isNull);
expect(joined.owner!.name, '라이브 집사');
expect(joined.members.where((m) => m.isMe), hasLength(1));
expect(joined.targets.single.isSelf, isTrue);
// 멤버가 사진과 함께 기록하고, 주인이 댓글을 단다
final memberPost = await member.addPost(diaryId: joined.id, date: day, text: '같이 산책', photoPath: photoPath);
await owner.addComment(memberPost.id, '고마워요');
final ownerBook = await owner.fetch();
final seen = ownerBook.posts.firstWhere((p) => p.id == memberPost.id);
expect(seen.isMine, isFalse);
expect(seen.author!.id, memberId);
expect(ownerBook.diaries.firstWhere((d) => d.id == shared.id).memberCount, 2);
// 서로의 사진을 받을 수 있다 — 주인은 멤버의 기록 사진, 멤버는 주인의 표지
final postPhoto = await dio.get<List<int>>(
'$kFilesPath/${serverFileIdOf(seen.photoPath)}',
options: Options(responseType: ResponseType.bytes),
);
expect(postPhoto.data, _png);
final cover = await memberDio.get<List<int>>(
'$kFilesPath/${serverFileIdOf(joined.coverPath)}',
options: Options(responseType: ResponseType.bytes),
);
expect(cover.data, _png);
// 멤버에게는 주인의 댓글이 남의 댓글로 온다
memberBook = await member.fetch();
expect(memberBook.comments.single.isMine, isFalse);
expect(memberBook.comments.single.author!.name, '라이브 집사');
// 내보내면 멤버의 책장에서 사라지고, 예전 코드로는 다시 들어올 수 없다
await owner.removeMember(shared.id, memberId);
expect((await member.fetch()).diaries, isEmpty);
await expectLater(
member.previewInvitation(code),
throwsA(isA<DioException>().having((e) => (e.error! as ApiException).code, 'code', 'DIARY_003')),
);
final rotated = (await owner.fetch()).diaries.firstWhere((d) => d.id == shared.id);
expect(rotated.inviteCode, isNot(code));
expect(rotated.memberCount, 1);
reported.removeWhere((e) => e.code == 'DIARY_003');
expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다');
});
test('규칙 위반은 사전 문구의 에러 코드로 돌아온다', () async {
final diaries = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => null);
final diary = (await diaries.fetch()).diaries.first;