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
+152 -2
View File
@@ -4,6 +4,7 @@ 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/diary_invitation.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'support/fake_api.dart';
@@ -113,10 +114,10 @@ void main() {
expect(api.reported.single.message, isNot('서버 문구'));
});
test('다이어리 수정은 바탕색을 보내지 않는다 (만들 때 정한 값을 유지한다)', () async {
test('다이어리 수정은 바탕색·알림을 보내지 않는다 (바탕색은 만들 때 정하고, 알림은 멤버마다 따로 둔다)', () async {
api.on('PATCH', '/diaries/:id', (r) {
expect(r.json!.containsKey('tint'), isFalse);
expect(r.json!['mutedNotices'], ['newComment']);
expect(r.json!.containsKey('mutedNotices'), isFalse);
return ok(_diaryJson());
});
final diary = Diary(
@@ -133,6 +134,155 @@ void main() {
expect(api.sent('PATCH', '/diaries/d1'), hasLength(1));
});
test('내 알림 설정은 멤버 알림 경로로 보낸다', () async {
api.on('PUT', '/diaries/:id/muted-notices', (r) {
expect(r.json, {
'mutedNotices': ['newPost'],
});
return noContent;
});
await repo.setMutedNotices('d1', const {DiaryNotice.newPost});
expect(api.sent('PUT', '/diaries/d1/muted-notices'), hasLength(1));
});
group('공유 다이어리', () {
Map<String, Object?> sharedJson({bool isOwner = true}) => {
..._diaryJson(),
'shared': true,
'isOwner': isOwner,
'ownerId': isOwner ? 'me-uuid' : 'friend-uuid',
'inviteCode': isOwner ? 'RUP-8F3K2Q' : null,
'members': [
{'userId': isOwner ? 'me-uuid' : 'friend-uuid', 'nickname': '콩이아빠', 'photoFileId': 'face-1', 'isOwner': true},
{'userId': isOwner ? 'friend-uuid' : 'me-uuid', 'nickname': '콩이엄마', 'photoFileId': null, 'isOwner': false},
],
'targets': [
{'id': 'p1', 'name': '콩이', 'photoFileId': 'pet-1', 'tint': '#FF8A5B', 'archived': false},
{'id': 'self', 'name': '콩이아빠', 'photoFileId': 'face-1', 'tint': null, 'archived': false},
],
};
test('참여한 다이어리는 멤버의 나를 알아보고, 주인의 아이들을 서버가 준 이름·사진으로 담는다', () async {
api.on('GET', '/diaries', (r) => ok(page([sharedJson(isOwner: false)])));
api.on('GET', '/diary-posts', (r) => ok(page([
{
..._postJson(authorId: 'friend-uuid'),
'author': {'userId': 'friend-uuid', 'nickname': '콩이아빠', 'photoFileId': 'face-1'},
},
])));
api.on('GET', '/diary-comments', (r) => ok(page(const [])));
final book = await repo.fetch();
final diary = book.diaries.single;
expect(diary.shared, isTrue);
expect(diary.isOwner, isFalse);
expect(diary.inviteCode, isNull);
expect(diary.owner!.name, '콩이아빠');
expect(diary.members.last.isMe, isTrue);
expect(diary.targets.map((t) => t.name), ['콩이', '콩이아빠']);
expect(diary.targets.first.photoPath, 'server-file:pet-1');
expect(diary.targets.last.isSelf, isTrue);
// 주인의 아이들이라 내 추억 보관함과 상관없이 책장에 둔다
expect(diary.isArchivedWith({'p1'}), isFalse);
final post = book.posts.single;
expect(post.isMine, isFalse);
expect(post.author!.name, '콩이아빠');
expect(post.author!.photoPath, 'server-file:face-1');
});
test('내 다이어리는 아이 요약을 담지 않는다 — 내 반려동물 목록으로 그린다', () {
final diary = diaryFromApi(sharedJson(), myUserId: 'me-uuid');
expect(diary.isOwner, isTrue);
expect(diary.inviteCode, 'RUP-8F3K2Q');
expect(diary.targets, isEmpty);
expect(diary.memberCount, 2);
expect(diary.members.first.isMe, isTrue);
});
test('만들 때 공유 여부를 보낸다', () async {
api.on('POST', '/diaries', (r) {
expect(r.json!['shared'], isTrue);
return ok(sharedJson(), status: 201);
});
final created = await repo.createDiary(
name: '콩이의 하루',
targetIds: const ['p1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
shared: true,
);
expect(created!.shared, isTrue);
});
test('개인 ↔ 공유 전환', () async {
api.on('PUT', '/diaries/:id/sharing', (r) {
expect(r.json, {'shared': false});
return ok(_diaryJson());
});
final diary = await repo.setShared('d1', false);
expect(diary.shared, isFalse);
});
test('초대 코드로 미리 보고, 참여하면 참여한 다이어리의 기록까지 다시 읽는다', () async {
api.on('GET', '/diary-invitations/:code', (r) => ok({
'diaryId': 'd1',
'name': '콩이의 하루',
'ownerNickname': '콩이아빠',
'memberCount': 2,
'maxMembers': 4,
'isMember': false,
}));
api.on('POST', '/diary-memberships', (r) {
expect(r.json, {'code': 'RUP-8F3K2Q'});
return ok(sharedJson(isOwner: false), status: 201);
});
api.on('GET', '/diaries', (r) => ok(page([sharedJson(isOwner: false)])));
api.on('GET', '/diary-posts', (r) => ok(page(const [])));
api.on('GET', '/diary-comments', (r) => ok(page(const [])));
final invitation = await repo.previewInvitation('RUP-8F3K2Q');
final book = await repo.join(invitation.code);
expect(invitation.name, '콩이의 하루');
expect(invitation.isFull, isFalse);
expect(api.sent('GET', '/diary-invitations/RUP-8F3K2Q'), hasLength(1));
expect(book.diaries.single.isOwner, isFalse);
});
test('나가기 · 멤버 내보내기', () async {
api.on('DELETE', '/diaries/:id/membership', (r) => noContent);
api.on('DELETE', '/diaries/:id/members/:userId', (r) => noContent);
await repo.leave('d1');
await repo.removeMember('d1', 'friend-uuid');
expect(api.sent('DELETE', '/diaries/d1/membership'), hasLength(1));
expect(api.sent('DELETE', '/diaries/d1/members/friend-uuid'), hasLength(1));
});
});
group('parseInviteCode', () {
test('QR 주소·코드·소문자·앞머리 없는 6자를 모두 초대 코드로 읽는다', () {
expect(parseInviteCode('rup://diary-invite/RUP-8F3K2Q'), 'RUP-8F3K2Q');
expect(parseInviteCode(' rup-8f3k2q '), 'RUP-8F3K2Q');
expect(parseInviteCode('8F3K2Q'), 'RUP-8F3K2Q');
});
test('초대 코드가 아니면 null', () {
expect(parseInviteCode('RUP-8F3K'), isNull);
expect(parseInviteCode('rup://pet-card/0428-117'), isNull);
expect(parseInviteCode('RUP-../../'), isNull);
});
});
test('아이를 지울 때 다이어리 정리는 서버가 이미 했다', () async {
await repo.dropTarget('p1');
+1 -1
View File
@@ -5,8 +5,8 @@ import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/qr_scanner_page.dart';
import 'support/app_harness.dart';
+397
View File
@@ -0,0 +1,397 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/api_exception.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/diary_invitation.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart';
import 'support/app_harness.dart';
const String _kCode = 'RUP-8F3K2Q';
const DiaryMember _me = DiaryMember(id: kMeAuthorId, nickname: '나집사');
const DiaryMember _dad = DiaryMember(id: 'dad', nickname: '콩이아빠', isOwner: true);
const DiaryMember _mom = DiaryMember(id: 'mom', nickname: '콩이엄마');
/// 내가 만든 공유 다이어리 — 나(주인) + 콩이엄마
Diary _ownedShared() => Diary(
id: 'd1',
name: '콩이 성장일기',
targetIds: const ['pet-1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
shared: true,
inviteCode: _kCode,
members: const [DiaryMember(id: kMeAuthorId, nickname: '나집사', isOwner: true), _mom],
);
/// 초대받아 참여한 다이어리 — 콩이아빠(주인) + 나
Diary _joined({String id = 'd2', String name = '함께 쓰는 다이어리'}) => Diary(
id: id,
name: name,
targetIds: const ['dad-pet'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
shared: true,
isOwner: false,
members: const [_dad, _me],
targets: const [DiaryTarget(id: 'dad-pet', name: '초코')],
);
/// 서버 없이 공유 다이어리를 흉내 내는 저장소
class _FakeSharedRepo implements DiaryRepository {
_FakeSharedRepo(this.book, {this.invitation});
DiaryBook book;
final DiaryInvitation? invitation;
final calls = <String>[];
@override
bool get canShare => true;
@override
Future<DiaryBook> fetch() async => book;
@override
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
bool shared = false,
}) async {
calls.add('create shared=$shared');
final diary = Diary(
id: 'new',
name: name,
targetIds: targetIds,
ribbon: ribbon,
tint: tint,
shared: shared,
inviteCode: shared ? _kCode : null,
members: shared ? const [DiaryMember(id: kMeAuthorId, isOwner: true)] : const [],
);
book = book.addDiary(diary);
return diary;
}
@override
Future<Diary> updateDiary(Diary diary) async => diary;
@override
Future<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted) async => calls.add('muted $muted');
@override
Future<Diary> setShared(String diaryId, bool shared) async {
calls.add('shared $diaryId $shared');
final d = book.diary(diaryId)!;
final next = Diary(
id: d.id,
name: d.name,
targetIds: d.targetIds,
ribbon: d.ribbon,
tint: d.tint,
shared: shared,
inviteCode: shared ? _kCode : null,
members: shared ? d.members.where((m) => m.isOwner).toList() : const [],
);
book = book.updateDiary(next);
return next;
}
@override
Future<DiaryInvitation> previewInvitation(String code) async {
calls.add('preview $code');
final found = invitation;
if (found == null) throw ApiException.fromCode('DIARY_003');
return found;
}
@override
Future<DiaryBook> join(String code) async {
calls.add('join $code');
book = book.addDiary(_joined());
return book;
}
@override
Future<void> leave(String diaryId) async => calls.add('leave $diaryId');
@override
Future<void> removeMember(String diaryId, String memberId) async {
calls.add('remove $diaryId $memberId');
// 서버처럼 멤버를 빼고 초대 코드를 바꾼다
final d = book.diary(diaryId)!;
book = book.updateDiary(Diary(
id: d.id,
name: d.name,
targetIds: d.targetIds,
ribbon: d.ribbon,
tint: d.tint,
shared: d.shared,
inviteCode: 'RUP-NEW000',
members: d.members.where((m) => m.id != memberId).toList(),
));
}
@override
Future<void> deleteDiary(String diaryId) async {}
@override
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
}) async =>
DiaryPost(id: 'p-new', diaryId: diaryId, date: date, createdAt: date, text: text);
@override
Future<DiaryPost> updatePostText(DiaryPost post, String text) async => post.copyWith(text: text);
@override
Future<void> deletePost(String postId) async {}
@override
Future<DiaryComment> addComment(String postId, String text) async =>
DiaryComment(id: 'c-new', postId: postId, text: text, createdAt: DateTime.now());
@override
Future<DiaryComment> updateComment(DiaryComment comment, String text) async => comment.copyWith(text: text);
@override
Future<void> deleteComment(String commentId) async {}
@override
Future<void> dropTarget(String targetId) async {}
}
Future<_FakeSharedRepo> _launch(WidgetTester tester, DiaryBook book, {DiaryInvitation? invitation}) async {
final repo = _FakeSharedRepo(book, invitation: invitation);
await launchSignedIn(
tester,
extra: {'pets.v1': jsonEncode([samplePet().toJson()])},
overrides: [diaryRepositoryProvider.overrideWithValue(repo)],
);
return repo;
}
Future<void> _openShelf(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('일정·기록'));
await settle(tester);
await tester.tap(find.text('다이어리'));
await settle(tester);
}
Future<void> _openDiary(WidgetTester tester, String name) async {
await _openShelf(tester);
await tester.tap(find.text(name));
await settle(tester);
expect(find.byType(DiaryPage), findsOneWidget);
}
Future<void> _openSettings(WidgetTester tester, String name) async {
await _openDiary(tester, name);
await tester.tap(find.bySemanticsLabel('다이어리 설정'));
await settle(tester);
expect(find.byType(DiarySettingsPage), findsOneWidget);
}
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pump();
await tester.tap(finder);
await settle(tester);
}
void main() {
testWidgets('주인은 멤버와 초대 코드를 보고, 멤버를 내보낼 수 있다', (tester) async {
final repo = await _launch(tester, DiaryBook(diaries: [_ownedShared()]));
await _openDiary(tester, '콩이 성장일기');
expect(find.text('2/4'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('다이어리 설정'));
await settle(tester);
expect(find.text('멤버'), findsOneWidget);
expect(find.text('2/4'), findsOneWidget);
expect(find.text('주인'), findsOneWidget);
expect(find.text('콩이엄마'), findsOneWidget);
expect(find.text('2명 더 초대할 수 있어요'), findsOneWidget);
await tester.ensureVisible(find.text(_kCode));
expect(find.text(_kCode), findsOneWidget);
await _tapVisible(tester, find.bySemanticsLabel('콩이엄마님 내보내기'));
expect(find.text('콩이엄마님을 내보낼까요?'), findsOneWidget);
await tester.tap(find.widgetWithText(GestureDetector, '내보내기').last);
await settle(tester);
expect(repo.calls, contains('remove d1 mom'));
expect(find.text('콩이엄마님을 내보냈어요'), findsOneWidget);
expect(find.text('1/4'), findsOneWidget);
// 내보내면 서버가 초대 코드를 바꾼다 — 다시 읽어 새 코드를 보여 준다
await tester.ensureVisible(find.text('RUP-NEW000'));
expect(find.text(_kCode), findsNothing);
});
testWidgets('멤버가 있는 다이어리를 개인으로 바꾸면 먼저 묻는다', (tester) async {
final repo = await _launch(tester, DiaryBook(diaries: [_ownedShared()]));
await _openSettings(tester, '콩이 성장일기');
await tester.tap(find.text('개인 다이어리'));
await settle(tester);
expect(find.text('개인 다이어리로 전환할까요?'), findsOneWidget);
await tester.tap(find.text('전환하기'));
await settle(tester);
expect(repo.calls, contains('shared d1 false'));
expect(find.text('개인 다이어리로 전환했어요'), findsOneWidget);
expect(find.text('멤버'), findsNothing);
});
testWidgets('참여한 다이어리는 만든 사람을 보여 주고, 나갈 수 있다', (tester) async {
final repo = await _launch(tester, DiaryBook(diaries: [_joined()]));
await _openSettings(tester, '함께 쓰는 다이어리');
expect(find.text('콩이아빠님이 만든 다이어리예요'), findsOneWidget);
expect(find.bySemanticsLabel('다이어리 수정하기'), findsNothing);
expect(find.text('내보내기'), findsNothing);
expect(find.text('다이어리 제거'), findsNothing);
await _tapVisible(tester, find.text('다이어리 나가기'));
expect(find.text('다이어리에서 나갈까요?'), findsOneWidget);
await tester.tap(find.text('나가기'));
await settle(tester);
expect(repo.calls, contains('leave d2'));
expect(find.text('다이어리에서 나왔어요'), findsOneWidget);
expect(find.byType(DiarySettingsPage), findsNothing);
});
testWidgets('초대 코드를 입력하고 확인하면 공유 다이어리에 참여한다', (tester) async {
final repo = await _launch(
tester,
DiaryBook.empty,
invitation: const DiaryInvitation(
code: _kCode,
diaryId: 'd2',
name: '함께 쓰는 다이어리',
ownerNickname: '콩이아빠',
memberCount: 1,
maxMembers: 4,
isMember: false,
),
);
await _openShelf(tester);
await tester.tap(find.bySemanticsLabel('새 다이어리'));
await settle(tester);
await tester.tap(find.text('공유 다이어리 참여하기'));
await settle(tester);
expect(find.text('공유 다이어리 참여'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'rup-8f3k2q');
await tester.pump();
await tester.tap(find.text('참여하기'));
await settle(tester);
expect(repo.calls, contains('preview $_kCode'));
expect(find.text('함께 쓰는 다이어리에\n참여하시겠어요?'), findsOneWidget);
await tester.tap(find.text('참여하기'));
await settle(tester);
expect(repo.calls, contains('join $_kCode'));
expect(find.text('공유 다이어리에 참여했어요'), findsOneWidget);
expect(find.text('함께 쓰는 다이어리'), findsOneWidget);
});
testWidgets('없는 초대 코드는 서버 안내로 멈추고 시트를 열어 둔다', (tester) async {
final repo = await _launch(tester, DiaryBook.empty);
await _openShelf(tester);
await tester.tap(find.bySemanticsLabel('새 다이어리'));
await settle(tester);
await tester.tap(find.text('공유 다이어리 참여하기'));
await settle(tester);
await tester.enterText(find.byType(TextField), 'RUP-ZZZZZZ');
await tester.pump();
await tester.tap(find.text('참여하기'));
await settle(tester);
expect(repo.calls, ['preview RUP-ZZZZZZ']);
expect(find.text('공유 다이어리 참여'), findsOneWidget);
});
testWidgets('공유 다이어리로 만들 수 있다', (tester) async {
final repo = await _launch(tester, DiaryBook.empty);
await _openShelf(tester);
await tester.tap(find.bySemanticsLabel('새 다이어리'));
await settle(tester);
await tester.tap(find.text('새 다이어리 만들기'));
await settle(tester);
await tester.tap(find.text('공유 다이어리'));
await tester.pump();
expect(find.text('만든 뒤 가족·지인을 초대해 함께 기록할 수 있어요.'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '가족 일기');
await _tapVisible(tester, find.text('다이어리 만들기'));
expect(repo.calls, contains('create shared=true'));
expect(find.text('공유 다이어리를 만들었어요'), findsOneWidget);
});
testWidgets('다른 멤버의 기록은 그 사람 이름으로 보이고, 고치거나 지울 수 없다', (tester) async {
final today = DateTime.now();
final post = DiaryPost(
id: 'p-dad',
diaryId: 'd2',
date: DateTime(today.year, today.month, today.day),
createdAt: today,
text: '초코랑 산책했어요',
authorId: 'dad',
author: _dad,
);
final comment = DiaryComment(id: 'c-dad', postId: 'p-dad', text: '귀엽다', createdAt: today, authorId: 'dad', author: _dad);
await _launch(tester, DiaryBook(diaries: [_joined()], posts: [post], comments: [comment]));
await _openDiary(tester, '함께 쓰는 다이어리');
expect(find.text('초코랑 산책했어요'), findsOneWidget);
expect(find.text('콩이아빠'), findsOneWidget);
expect(find.bySemanticsLabel('기록 관리'), findsNothing);
await _tapVisible(tester, find.textContaining('댓글'));
expect(find.text('귀엽다'), findsOneWidget);
expect(find.text('수정'), findsNothing);
expect(find.text('삭제'), findsNothing);
});
testWidgets('다른 멤버의 새 글은 알림에 나온다', (tester) async {
final now = DateTime.now();
final post = DiaryPost(
id: 'p-dad',
diaryId: 'd2',
date: DateTime(now.year, now.month, now.day),
createdAt: now.subtract(const Duration(minutes: 5)),
text: '초코랑 산책했어요',
authorId: 'dad',
author: _dad,
);
await _launch(tester, DiaryBook(diaries: [_joined()], posts: [post]));
// 새 글이 있으니 종에 점이 찍혀 있다
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
await settle(tester);
expect(find.text('콩이아빠님이 새 글을 올렸어요'), findsOneWidget);
expect(find.text('“초코랑 산책했어요”'), findsOneWidget);
// 저녁 8시가 지났으면 기록 알림도 함께 나온다
expect(find.textContaining('함께 쓰는 다이어리 ·'), findsWidgets);
});
}