feat(flutter): 일정 함께 관리 — 초대 코드로 달력 연동

가족·친구와 같은 달력을 함께 쓰는 화면을 연다.
- 함께 관리: 일정 연동 켜기·끄기, 연동된 사람, 내 초대 QR(앨범 저장)·코드 복사, 코드로 연동, 연동 해제 확인
- 코드 입력 시트에서 초대 QR 스캔으로도 연동할 수 있다 (공용 QR 스캐너)
- 연동 중이면 일정마다 만든 사람을 보여 주고, 남의 일정은 그 사람의 아이 이름·사진으로 그린다
- 남이 만든 일정은 이름·분류·시간·장소·색만 고친다 (대상·반복·기간은 만든 사람이 바꾼다)
- 연동·해제하면 일정을 다시 읽는다
- 기기 저장 모드에서는 함께 관리를 쓸 수 없다고 안내한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 21:31:58 +09:00
parent b6c5496a05
commit 61cfc4461f
20 changed files with 2023 additions and 108 deletions
@@ -35,6 +35,8 @@ import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
import 'package:tma_app/features/schedule/data/calendar_sharing_repository.dart';
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
@@ -332,6 +334,84 @@ void main() {
expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다');
});
test('함께 관리 — 초대 코드로 연동하면 서로의 일정이 보이고 고칠 수 있으며, 해제하면 내 일정만 남는다', () async {
final ownerId = prefs.getString(kAuthUserIdKey)!;
final friendSession = SessionStore(MemoryTokenVault());
final friendDio = DioClient(baseUrl: baseUrl, session: friendSession, onError: reported.add).dio;
final friendAuth = ApiAuthRepository(
dio: friendDio,
session: friendSession,
prefs: prefs,
deviceKeys: FixedDeviceKeyStore('live-friend-${DateTime.now().microsecondsSinceEpoch}'),
);
await friendAuth.signIn(SocialProvider.kakao);
final friendId = prefs.getString(kAuthUserIdKey)!;
await prefs.setString(kAuthUserIdKey, ownerId);
addTearDown(() async {
if (friendSession.hasSession) await friendAuth.deleteAccount();
await prefs.setString(kAuthUserIdKey, ownerId);
});
final mySharing = ApiCalendarSharingRepository(dio: dio, session: session);
final friendSharing = ApiCalendarSharingRepository(dio: friendDio, session: friendSession);
final mySchedule = ApiScheduleRepository(dio: dio, session: session, myUserId: () => ownerId);
final friendSchedule = ApiScheduleRepository(dio: friendDio, session: friendSession, myUserId: () => friendId);
final pets = ApiPetRepository(dio: dio, session: session, uploader: uploader);
final today = DateTime.now();
final day = DateTime(today.year, today.month, today.day);
// 내 아이·일정 — 연동한 친구에게 보여야 한다
final bori = await pets.create(_pet('보리'), tint: kPetTints[2]);
final mine = (await mySchedule.createEvents([(date: day, details: _details('보리 산책', [bori.id]))])).single;
final sharing = (await mySharing.fetch())!;
expect(parseCalendarCode(sharing.inviteCode), sharing.inviteCode);
expect(sharing.linked, isFalse);
expect(sharing.members.single.isMe, isTrue);
// 친구 — 내 코드를 미리 보고 연동한다
final invitation = await friendSharing.previewInvitation(sharing.inviteCode);
expect(invitation.isMine, isFalse);
expect(invitation.ownerNickname, '라이브 집사');
final linked = await friendSharing.join(invitation.code);
expect(linked.linked, isTrue);
expect(linked.members, hasLength(2));
expect(linked.members.first.isMe, isTrue);
// 친구에게 내 일정이 남의 일정으로 온다 — 만든 사람과 내 아이 이름이 붙는다
var friendBook = await friendSchedule.fetch();
final seen = friendBook.events.firstWhere((e) => e.id == mine.id);
expect(seen.details.isMine, isFalse);
expect(seen.details.owner!.name, '라이브 집사');
expect(seen.details.sharedTargets.single.name, '보리');
// 친구가 내 일정의 이름을 고치고 완료하면 나에게도 보인다
await friendSchedule.updateEvent(seen, _details('보리 공원 산책', [bori.id]));
await friendSchedule.setDone(seen.id, done: true);
var myBook = await mySchedule.fetch();
final updated = myBook.events.firstWhere((e) => e.id == mine.id);
expect(updated.details.title, '보리 공원 산책');
expect(updated.details.isMine, isTrue);
expect(myBook.isDone(mine.id), isTrue);
// 친구의 일정 — 나에게 보인다
final friendEvent = (await friendSchedule.createEvents([(date: day, details: _details('친구 일정', ['self']))])).single;
myBook = await mySchedule.fetch();
expect(myBook.events.firstWhere((e) => e.id == friendEvent.id).details.owner!.id, friendId);
// 친구가 해제하면 서로의 일정이 사라지고 각자 자기 일정만 남는다
await friendSharing.leave();
friendBook = await friendSchedule.fetch();
expect(friendBook.events.map((e) => e.id), [friendEvent.id]);
myBook = await mySchedule.fetch();
expect(myBook.events.map((e) => e.id), contains(mine.id));
expect(myBook.events.map((e) => e.id), isNot(contains(friendEvent.id)));
expect((await mySharing.fetch())!.linked, isFalse);
await pets.delete(bori.id);
expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다');
});
test('규칙 위반은 사전 문구의 에러 코드로 돌아온다', () async {
final diaries = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => null);
final diary = (await diaries.fetch()).diaries.first;