generated from ttipo/project-base
feat(flutter): 일정 함께 관리 — 초대 코드로 달력 연동
가족·친구와 같은 달력을 함께 쓰는 화면을 연다. - 함께 관리: 일정 연동 켜기·끄기, 연동된 사람, 내 초대 QR(앨범 저장)·코드 복사, 코드로 연동, 연동 해제 확인 - 코드 입력 시트에서 초대 QR 스캔으로도 연동할 수 있다 (공용 QR 스캐너) - 연동 중이면 일정마다 만든 사람을 보여 주고, 남의 일정은 그 사람의 아이 이름·사진으로 그린다 - 남이 만든 일정은 이름·분류·시간·장소·색만 고친다 (대상·반복·기간은 만든 사람이 바꾼다) - 연동·해제하면 일정을 다시 읽는다 - 기기 저장 모드에서는 함께 관리를 쓸 수 없다고 안내한다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user