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
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.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 'support/fake_api.dart';
Map<String, Object?> _sharingJson({bool linked = true}) => {
'inviteCode': 'RUP-8K2D7Q',
'linked': linked,
'maxMembers': 4,
'members': [
{'userId': 'me-uuid', 'nickname': '나집사', 'photoFileId': null, 'isMe': true},
if (linked) {'userId': 'dad-uuid', 'nickname': '콩이아빠', 'photoFileId': 'face-1', 'isMe': false},
],
};
Map<String, Object?> _eventJson({required String ownerId}) => {
'id': 'e1',
'date': '2026-10-11',
'title': '산책',
'kind': 'general',
'category': 'walk',
'targetIds': ['dad-pet', 'self'],
'start': '08:30',
'end': null,
'place': null,
'labelColor': null,
'isDone': false,
'ownerId': ownerId,
'owner': {'userId': ownerId, 'nickname': '콩이아빠', 'photoFileId': 'face-1'},
'targets': [
{'id': 'dad-pet', 'name': '초코', 'photoFileId': 'pet-1', 'tint': '#C9794A'},
{'id': 'self', 'name': '콩이아빠', 'photoFileId': 'face-1', 'tint': null},
],
};
void main() {
group('parseCalendarCode', () {
test('QR 주소·코드·소문자·앞머리 없는 6자를 모두 초대 코드로 읽는다', () {
expect(parseCalendarCode('rup://calendar-invite/RUP-8K2D7Q'), 'RUP-8K2D7Q');
expect(parseCalendarCode(' rup-8k2d7q '), 'RUP-8K2D7Q');
expect(parseCalendarCode('8k2d7q'), 'RUP-8K2D7Q');
});
test('초대 코드가 아니면 null', () {
expect(parseCalendarCode('RUP-8K2'), isNull);
expect(parseCalendarCode('RUP-8K2D'), isNull, reason: '짐작하기 쉬운 4자 코드는 받지 않는다');
expect(parseCalendarCode('rup://diary-invite/RUP-8K2D7Q'), isNull);
});
});
group('ApiCalendarSharingRepository', () {
late FakeApi api;
late ApiCalendarSharingRepository repo;
setUp(() {
api = FakeApi();
repo = ApiCalendarSharingRepository(dio: api.dio, session: api.session);
});
test('내 초대 코드와 연동된 사람들을 읽는다 — 나 먼저', () async {
api.on('GET', '/calendar-sharing', (r) => ok(_sharingJson()));
final sharing = (await repo.fetch())!;
expect(sharing.inviteCode, 'RUP-8K2D7Q');
expect(sharing.linked, isTrue);
expect(sharing.members.first.isMe, isTrue);
expect(sharing.members.last.name, '콩이아빠');
expect(sharing.members.last.photoPath, 'server-file:face-1');
expect(sharing.isFull, isFalse);
});
test('받은 코드를 미리 보고 연동한다', () async {
api.on('GET', '/calendar-invitations/:code', (r) => ok({
'ownerNickname': '콩이아빠',
'memberCount': 1,
'maxMembers': 4,
'isMine': false,
'isMember': false,
}));
api.on('POST', '/calendar-memberships', (r) {
expect(r.json, {'code': 'RUP-8K2D7Q'});
return ok(_sharingJson(), status: 201);
});
final invitation = await repo.previewInvitation('RUP-8K2D7Q');
final sharing = await repo.join(invitation.code);
expect(invitation.ownerNickname, '콩이아빠');
expect(invitation.isFull, isFalse);
expect(api.sent('GET', '/calendar-invitations/RUP-8K2D7Q'), hasLength(1));
expect(sharing.linked, isTrue);
});
test('연동 해제', () async {
api.on('DELETE', '/calendar-membership', (r) => noContent);
await repo.leave();
expect(api.sent('DELETE', '/calendar-membership'), hasLength(1));
});
test('로그인 전에는 묻지 않는다', () async {
final signedOut = FakeApi(tokens: null);
expect(await ApiCalendarSharingRepository(dio: signedOut.dio, session: signedOut.session).fetch(), isNull);
expect(signedOut.requests, isEmpty);
});
});
group('함께 관리 일정', () {
test('남이 만든 일정에는 만든 사람과 대상 이름·사진이 붙는다', () {
final event = scheduleEventFromApi(_eventJson(ownerId: 'dad-uuid'), myUserId: 'me-uuid');
expect(event.details.isMine, isFalse);
expect(event.details.owner!.name, '콩이아빠');
expect(event.details.owner!.photoPath, 'server-file:face-1');
expect(event.details.sharedTargets.map((t) => t.name), ['초코', '콩이아빠']);
expect(event.details.sharedTargets.first.photoPath, 'server-file:pet-1');
expect(event.details.sharedTargets.last.isSelf, isTrue);
expect(event.details.targetIds, ['dad-pet', 'self'], reason: '수정할 때 보내는 대상은 그대로');
});
test('내 일정은 그대로 — 내 반려동물 목록으로 그린다', () {
final event = scheduleEventFromApi(_eventJson(ownerId: 'me-uuid'), myUserId: 'me-uuid');
expect(event.details.isMine, isTrue);
expect(event.details.sharedTargets, isEmpty);
});
test('반복 일정도 만든 사람이 붙고, 날짜별 일정이 이어받는다', () {
final rule = repeatRuleFromApi({
'id': 'r1',
'from': '2026-10-01',
'spec': {'mode': 'daily', 'unit': 'week', 'n': 1},
'details': {
'title': '약 먹기',
'kind': 'general',
'category': 'med',
'targetIds': ['dad-pet'],
'start': null,
'end': null,
'place': null,
'labelColor': null,
},
'ownerId': 'dad-uuid',
'owner': {'userId': 'dad-uuid', 'nickname': '콩이아빠', 'photoFileId': null},
'targets': [
{'id': 'dad-pet', 'name': '초코', 'photoFileId': null, 'tint': '#C9794A'},
],
}, myUserId: 'me-uuid');
final instance = rule.instanceOn(DateTime(2026, 10, 11));
expect(instance.details.owner!.name, '콩이아빠');
expect(instance.details.sharedTargets.single.name, '초코');
});
});
}
@@ -0,0 +1,249 @@
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/core/utils/date_format.dart';
import 'package:tma_app/features/schedule/data/calendar_sharing_repository.dart';
import 'package:tma_app/features/schedule/data/schedule_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_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/pages/share_calendar_page.dart';
import 'support/app_harness.dart';
const CalendarPerson _me = CalendarPerson(id: 'me', nickname: '나집사', isMe: true);
const CalendarPerson _dad = CalendarPerson(id: 'dad', nickname: '콩이아빠');
CalendarSharing _sharing({bool linked = false}) =>
CalendarSharing(inviteCode: 'RUP-8K2D7Q', linked: linked, members: [_me, if (linked) _dad]);
/// 서버 없이 함께 관리를 흉내 내는 저장소
class _FakeSharingRepo implements CalendarSharingRepository {
_FakeSharingRepo(this.sharing, {this.invitation});
CalendarSharing sharing;
final CalendarInvitation? invitation;
final calls = <String>[];
@override
bool get available => true;
@override
Future<CalendarSharing?> fetch() async => sharing;
@override
Future<CalendarInvitation> previewInvitation(String code) async {
calls.add('preview $code');
final found = invitation;
if (found == null) throw ApiException.fromCode('SCHED_001');
return found;
}
@override
Future<CalendarSharing> join(String code) async {
calls.add('join $code');
return sharing = _sharing(linked: true);
}
@override
Future<void> leave() async {
calls.add('leave');
sharing = _sharing();
}
}
/// 다른 사람이 만든 일정 하나가 든 저장소 (기기 저장소는 만든 사람을 담지 않는다)
class _FakeScheduleRepo implements ScheduleRepository {
_FakeScheduleRepo(this.book);
ScheduleBook book;
@override
Future<ScheduleBook> fetch() async => book;
@override
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async => [
for (final (i, d) in drafts.indexed) ScheduleEvent(id: 'new-$i', date: d.date, details: d.details),
];
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) async =>
event.copyWith(details: details.copyWith(owner: event.details.owner, sharedTargets: event.details.sharedTargets));
@override
Future<void> deleteEvent(String eventId) async {}
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) async =>
RepeatRule(id: 'rule-new', spec: spec, from: from, details: details);
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async =>
rule.copyWith(spec: spec, details: details);
@override
Future<void> deleteRule(String ruleId) async {}
@override
Future<void> setDone(String occurrenceId, {required bool done}) async {}
@override
Future<void> dropTarget(String targetId) async {}
}
ScheduleEvent _dadsWalk() => ScheduleEvent(
id: 'e-dad',
date: dateOnly(DateTime.now()),
details: const EventDetails(
title: '초코 산책',
kind: EventKind.general,
category: EventCategory.walk,
targetIds: ['dad-pet'],
owner: _dad,
sharedTargets: [EventTarget(id: 'dad-pet', name: '초코')],
),
);
Future<_FakeSharingRepo> _launch(
WidgetTester tester,
CalendarSharing sharing, {
CalendarInvitation? invitation,
ScheduleBook schedule = ScheduleBook.empty,
}) async {
final repo = _FakeSharingRepo(sharing, invitation: invitation);
await launchSignedIn(
tester,
extra: {'pets.v1': jsonEncode([samplePet().toJson()])},
overrides: [
calendarSharingRepositoryProvider.overrideWithValue(repo),
scheduleRepositoryProvider.overrideWithValue(_FakeScheduleRepo(schedule)),
],
);
await tester.tap(find.bySemanticsLabel('일정·기록'));
await settle(tester);
return repo;
}
Future<void> _openSharing(WidgetTester tester) async {
await tester.tap(find.text('함께 관리'));
await settle(tester);
expect(find.byType(ShareCalendarPage), 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 {
await _launch(tester, _sharing());
expect(find.text('가족·친구와 일정을 공유해보세요'), findsOneWidget);
await _openSharing(tester);
expect(find.text('가족·친구와 같은 달력을 공유 중이에요'), findsOneWidget);
expect(find.text('1/4'), findsOneWidget);
expect(find.text('나'), findsOneWidget);
expect(find.text('3명 더 초대할 수 있어요'), findsOneWidget);
await tester.ensureVisible(find.text('RUP-8K2D7Q'));
expect(find.text('RUP-8K2D7Q'), findsOneWidget);
});
testWidgets('받은 코드로 연동하면 확인한 뒤 같은 달력을 함께 쓴다', (tester) async {
final repo = await _launch(
tester,
_sharing(),
invitation: const CalendarInvitation(
code: 'RUP-7QW3XZ',
ownerNickname: '콩이아빠',
memberCount: 1,
maxMembers: 4,
isMine: false,
isMember: false,
),
);
await _openSharing(tester);
await _tapVisible(tester, find.text('코드로 연동'));
expect(find.text('초대 코드로 연동'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'rup-7qw3xz');
await tester.pump();
await tester.tap(find.text('연동하기'));
await settle(tester);
expect(repo.calls, ['preview RUP-7QW3XZ']);
expect(find.text('일정을 공유할까요?'), findsOneWidget);
await tester.tap(find.text('공유하기'));
await settle(tester);
expect(repo.calls, contains('join RUP-7QW3XZ'));
expect(find.text('새로운 멤버와 연동했어요'), findsOneWidget);
expect(find.text('2/4'), findsOneWidget);
expect(find.text('콩이아빠'), findsOneWidget);
});
testWidgets('내 초대 코드로는 연동하지 않고 서버와 같은 안내를 띄운다', (tester) async {
final repo = await _launch(
tester,
_sharing(),
invitation: const CalendarInvitation(
code: 'RUP-8K2D7Q',
ownerNickname: '나집사',
memberCount: 1,
maxMembers: 4,
isMine: true,
isMember: false,
),
);
await _openSharing(tester);
await _tapVisible(tester, find.text('코드로 연동'));
await tester.enterText(find.byType(TextField), 'RUP-8K2D7Q');
await tester.pump();
await tester.tap(find.text('연동하기'));
await settle(tester);
expect(find.text('일정을 공유할까요?'), findsNothing);
expect(repo.calls, isNot(contains('join RUP-8K2D7Q')));
});
testWidgets('연동을 끄면 먼저 묻고, 해제하면 내 일정만 남는다', (tester) async {
final repo = await _launch(tester, _sharing(linked: true));
expect(find.text('2명이 같은 달력을 함께 써요'), findsOneWidget);
await _openSharing(tester);
await tester.tap(find.bySemanticsLabel('일정 연동').last);
await settle(tester);
expect(find.text('연동을 해제할까요?'), findsOneWidget);
await tester.tap(find.text('연동 해제'));
await settle(tester);
expect(repo.calls, ['leave']);
expect(find.text('연동을 해제했어요'), findsOneWidget);
expect(find.text('연동을 켜면 초대 코드·QR로\n가족·친구와 같은 달력을 함께 쓸 수 있어요.'), findsOneWidget);
});
testWidgets('연동 중이면 일정마다 만든 사람과, 남의 아이 이름을 보여 준다', (tester) async {
await _launch(tester, _sharing(linked: true), schedule: ScheduleBook(events: [_dadsWalk()]));
expect(find.text('초코 산책'), findsOneWidget);
expect(find.text('초코'), findsOneWidget);
expect(find.text('콩이아빠'), findsOneWidget);
});
testWidgets('남이 만든 일정은 대상·반복·기간을 바꾸지 않고 내용만 고친다', (tester) async {
await _launch(tester, _sharing(linked: true), schedule: ScheduleBook(events: [_dadsWalk()]));
await tester.tap(find.text('초코 산책'));
await settle(tester);
expect(find.textContaining('콩이아빠님이 만든 일정'), findsOneWidget);
expect(find.text('반복 일정'), findsNothing);
expect(find.text('기간 설정'), findsNothing);
});
}