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
+12
View File
@@ -37,6 +37,8 @@ import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.d
import 'package:tma_app/features/pet_card/presentation/pages/received_card_detail_page.dart';
import 'package:tma_app/features/records/presentation/pages/records_page.dart';
import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart';
import 'package:tma_app/features/schedule/presentation/pages/share_calendar_page.dart';
import 'package:tma_app/features/schedule/presentation/widgets/calendar_link_flow.dart';
import 'package:tma_app/features/shell/presentation/pages/main_shell.dart';
import 'package:tma_app/features/splash/presentation/pages/splash_page.dart';
@@ -131,6 +133,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
),
GoRoute(
path: AppRoutes.shareCalendar,
parentNavigatorKey: rootKey,
builder: (context, state) => const ShareCalendarPage(),
),
GoRoute(
path: AppRoutes.calendarLinkScan,
parentNavigatorKey: rootKey,
pageBuilder: (context, state) => _fadePage(state, const CalendarLinkScannerPage()),
),
GoRoute(
path: AppRoutes.diaryJoinScan,
parentNavigatorKey: rootKey,
+4
View File
@@ -44,6 +44,10 @@ abstract final class AppRoutes {
static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}';
static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}';
// 함께 관리 — 달력 연동 (탭바 없이 전체 화면)
static const String shareCalendar = '/schedule/sharing';
static const String calendarLinkScan = '/schedule/sharing/scan';
// 알림 — 홈 종에서 연다 (탭바 없이 전체 화면)
static const String notifications = '/notifications';
@@ -194,7 +194,7 @@ class _DiarySettingsPageState extends ConsumerState<DiarySettingsPage> {
JoinedDiaryCard(diary: diary),
if (diary.shared) ...[
SizedBox(height: diary.isOwner ? 24 : 22),
SharingSectionTitle(title: '멤버', count: '${diary.memberCount}/$kMaxDiaryMembers'),
CountSectionTitle(title: '멤버', count: '${diary.memberCount}/$kMaxDiaryMembers'),
const SizedBox(height: AppSpacing.md),
DiaryMembersCard(
diary: diary,
@@ -20,6 +20,7 @@ import 'package:tma_app/shared/widgets/pressable.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
const double _kNormal = AppTypography.normalHeight;
const double _kMemberAvatar = 40;
@@ -29,36 +30,6 @@ const double _kZoomQr = 230;
TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color) =>
Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: _kNormal);
/// 섹션 제목 (시안 .rup-section-title h2) — 옆에 작은 보조 글
class SharingSectionTitle extends StatelessWidget {
const SharingSectionTitle({super.key, required this.title, this.count});
final String title;
final String? count;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
title,
style: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight),
),
if (count != null) ...[
const SizedBox(width: 9),
Text(count!, style: _ui(context, 13, FontWeight.w700, AppColors.muted)),
],
],
),
);
}
}
/// 멤버 사진 — 나면 내 프로필 사진
class MemberAvatar extends ConsumerWidget {
const MemberAvatar({super.key, required this.person, required this.size});
@@ -371,7 +342,7 @@ class DiaryInviteSection extends ConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SharingSectionTitle(title: '멤버 초대하기'),
const CountSectionTitle(title: '멤버 초대하기'),
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, AppSpacing.xs, AppSpacing.lg),
child: Text(
@@ -1,10 +1,12 @@
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/hour_minute.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';
@@ -42,10 +44,33 @@ EventDetails eventDetailsFromApi(Map<String, Object?> json) => EventDetails(
labelColor: colorFromHex(json['labelColor'] as String?),
);
ScheduleEvent scheduleEventFromApi(Map<String, Object?> json) => ScheduleEvent(
/// 함께 관리 — 남이 만든 일정이면 만든 사람과 대상 요약을 붙인다 (내 일정·내 id 를 모르면 그대로)
EventDetails withOwnerFromApi(EventDetails details, Map<String, Object?> json, String? myUserId) {
final ownerId = json['ownerId'] as String?;
if (ownerId == null || myUserId == null || ownerId == myUserId) return details;
final owner = json['owner'] as Map<String, Object?>?;
return details.copyWith(
owner: CalendarPerson(
id: ownerId,
nickname: owner?['nickname'] as String?,
photoPath: serverFileRefOrNull(owner?['photoFileId'] as String?),
),
sharedTargets: [
for (final t in (json['targets'] as List?)?.whereType<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
EventTarget(
id: t['id']! as String,
name: t['name'] as String?,
photoPath: serverFileRefOrNull(t['photoFileId'] as String?),
isSelf: t['id'] == kSelfTargetId,
),
],
);
}
ScheduleEvent scheduleEventFromApi(Map<String, Object?> json, {String? myUserId}) => ScheduleEvent(
id: json['id']! as String,
date: parseIsoDate(json['date']! as String),
details: eventDetailsFromApi(json),
details: withOwnerFromApi(eventDetailsFromApi(json), json, myUserId),
);
RepeatSpec repeatSpecFromApi(Map<String, Object?> json) => RepeatSpec(
@@ -54,23 +79,30 @@ RepeatSpec repeatSpecFromApi(Map<String, Object?> json) => RepeatSpec(
n: (json['n'] as num?)?.toInt() ?? 1,
);
RepeatRule repeatRuleFromApi(Map<String, Object?> json) => RepeatRule(
RepeatRule repeatRuleFromApi(Map<String, Object?> json, {String? myUserId}) => RepeatRule(
id: json['id']! as String,
from: parseIsoDate(json['from']! as String),
spec: repeatSpecFromApi(json['spec']! as Map<String, Object?>),
details: eventDetailsFromApi(json['details']! as Map<String, Object?>),
details: withOwnerFromApi(eventDetailsFromApi(json['details']! as Map<String, Object?>), json, myUserId),
);
Map<String, Object?> _specToApi(RepeatSpec spec) => {'mode': spec.mode.name, 'unit': spec.unit.name, 'n': spec.n};
/// 서버 저장소 — 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본(일정·규칙·완료 표시)을 둔다
/// 서버 저장소 — 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본(일정·규칙·완료 표시)을 둔다.
/// 함께 관리 중이면 연동된 사람들의 일정도 함께 오고, 남이 만든 일정에는 만든 사람이 붙는다.
class ApiScheduleRepository implements ScheduleRepository {
ApiScheduleRepository({required Dio dio, required SessionStore session})
ApiScheduleRepository({required Dio dio, required SessionStore session, String? Function()? myUserId})
: _dio = dio,
_session = session;
_session = session,
_myUserId = myUserId ?? (() => null);
final Dio _dio;
final SessionStore _session;
final String? Function() _myUserId;
ScheduleEvent _event(Map<String, Object?> json) => scheduleEventFromApi(json, myUserId: _myUserId());
RepeatRule _rule(Map<String, Object?> json) => repeatRuleFromApi(json, myUserId: _myUserId());
@override
Future<ScheduleBook> fetch() async {
@@ -78,11 +110,11 @@ class ApiScheduleRepository implements ScheduleRepository {
final done = <String>{};
final events = await fetchAllPages(_dio, _kEventsPath, (json) {
final event = scheduleEventFromApi(json);
final event = _event(json);
if (json['isDone'] == true) done.add(event.id);
return event;
});
final rules = await fetchAllPages(_dio, _kRulesPath, repeatRuleFromApi);
final rules = await fetchAllPages(_dio, _kRulesPath, _rule);
final completions = await fetchAllPages(
_dio,
_kCompletionsPath,
@@ -104,7 +136,7 @@ class ApiScheduleRepository implements ScheduleRepository {
],
},
);
created.addAll(response.data!.map((json) => scheduleEventFromApi(json as Map<String, Object?>)));
created.addAll(response.data!.map((json) => _event(json as Map<String, Object?>)));
}
return created;
}
@@ -115,7 +147,7 @@ class ApiScheduleRepository implements ScheduleRepository {
'$_kEventsPath/${event.id}',
data: eventDetailsToApi(details),
);
return scheduleEventFromApi(response.data!);
return _event(response.data!);
}
@override
@@ -127,7 +159,7 @@ class ApiScheduleRepository implements ScheduleRepository {
_kRulesPath,
data: {'from': toIsoDate(from), 'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
);
return repeatRuleFromApi(response.data!);
return _rule(response.data!);
}
@override
@@ -136,7 +168,7 @@ class ApiScheduleRepository implements ScheduleRepository {
'$_kRulesPath/${rule.id}',
data: {'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
);
return repeatRuleFromApi(response.data!);
return _rule(response.data!);
}
@override
@@ -0,0 +1,119 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
/// 함께 관리 경로 (backend CalendarSharingController)
const String _kSharingPath = '/calendar-sharing';
const String _kInvitationsPath = '/calendar-invitations';
const String _kMembershipsPath = '/calendar-memberships';
const String _kMembershipPath = '/calendar-membership';
/// 함께 관리 저장소 계약 — 다른 회원과 달력을 함께 쓰므로 서버가 있어야 한다
abstract interface class CalendarSharingRepository {
/// 함께 관리를 쓸 수 있는지 — 기기 저장 모드에서는 쓸 수 없다
bool get available;
/// 내 초대 코드와 연동된 사람들 (기기 저장 모드·로그인 전에는 null)
Future<CalendarSharing?> fetch();
/// 받은 초대 코드를 미리 본다
Future<CalendarInvitation> previewInvitation(String code);
/// 받은 초대 코드로 연동한다
Future<CalendarSharing> join(String code);
/// 연동을 해제한다 — 내 일정은 남고 다른 사람의 일정은 더 이상 보이지 않는다
Future<void> leave();
}
/// 기기 저장 모드 — 함께 관리를 쓸 수 없다 (화면은 안내만 띄운다)
class UnavailableCalendarSharingRepository implements CalendarSharingRepository {
const UnavailableCalendarSharingRepository();
@override
bool get available => false;
@override
Future<CalendarSharing?> fetch() async => null;
@override
Future<CalendarInvitation> previewInvitation(String code) => throw StateError(_kLocalOnly);
@override
Future<CalendarSharing> join(String code) => throw StateError(_kLocalOnly);
@override
Future<void> leave() async {}
}
const String _kLocalOnly = '함께 관리는 서버 모드에서만 쓸 수 있다';
CalendarSharing calendarSharingFromApi(Map<String, Object?> json) => CalendarSharing(
inviteCode: json['inviteCode']! as String,
linked: json['linked'] as bool? ?? false,
maxMembers: (json['maxMembers'] as num?)?.toInt() ?? kMaxCalendarMembers,
members: [
for (final m in (json['members'] as List?)?.whereType<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
CalendarPerson(
id: m['userId']! as String,
nickname: m['nickname'] as String?,
photoPath: serverFileRefOrNull(m['photoFileId'] as String?),
isMe: m['isMe'] as bool? ?? false,
),
],
);
CalendarInvitation calendarInvitationFromApi(String code, Map<String, Object?> json) => CalendarInvitation(
code: code,
ownerNickname: json['ownerNickname'] as String?,
memberCount: (json['memberCount'] as num?)?.toInt() ?? 1,
maxMembers: (json['maxMembers'] as num?)?.toInt() ?? kMaxCalendarMembers,
isMine: json['isMine'] as bool? ?? false,
isMember: json['isMember'] as bool? ?? false,
);
/// 서버 저장소
class ApiCalendarSharingRepository implements CalendarSharingRepository {
ApiCalendarSharingRepository({required Dio dio, required SessionStore session})
: _dio = dio,
_session = session;
final Dio _dio;
final SessionStore _session;
@override
bool get available => true;
@override
Future<CalendarSharing?> fetch() async {
if (!_session.hasSession) return null;
final response = await _dio.get<Map<String, dynamic>>(_kSharingPath);
return calendarSharingFromApi(response.data!);
}
@override
Future<CalendarInvitation> previewInvitation(String code) async {
final response = await _dio.get<Map<String, dynamic>>('$_kInvitationsPath/${Uri.encodeComponent(code)}');
return calendarInvitationFromApi(code, response.data!);
}
@override
Future<CalendarSharing> join(String code) async {
final response = await _dio.post<Map<String, dynamic>>(_kMembershipsPath, data: {'code': code});
return calendarSharingFromApi(response.data!);
}
@override
Future<void> leave() => _dio.delete<void>(_kMembershipPath);
}
final calendarSharingRepositoryProvider = Provider<CalendarSharingRepository>((ref) {
return switch (ref.watch(dataModeProvider)) {
DataMode.local => const UnavailableCalendarSharingRepository(),
DataMode.api => ApiCalendarSharingRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
};
});
@@ -7,6 +7,7 @@ import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/care_preset.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
@@ -134,7 +135,11 @@ class LocalScheduleRepository implements ScheduleRepository {
final scheduleRepositoryProvider = Provider<ScheduleRepository>((ref) {
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalScheduleRepository(ref.watch(sharedPreferencesProvider)),
DataMode.api => ApiScheduleRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
DataMode.api => ApiScheduleRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
myUserId: () => ref.read(sharedPreferencesProvider).getString(kAuthUserIdKey),
),
};
});
@@ -0,0 +1,96 @@
/// 함께 관리 — 한 달력을 최대 4명이 함께 쓴다 (시안 MAX_LINKED)
const int kMaxCalendarMembers = 4;
/// 닉네임을 모르는 사람
const String kUnknownCalendarPerson = '집사';
/// 달력을 함께 쓰는 사람 · 일정을 만든 사람
class CalendarPerson {
const CalendarPerson({required this.id, this.nickname, this.photoPath, this.isMe = false});
/// 회원 id
final String id;
final String? nickname;
final String? photoPath;
final bool isMe;
String get name => nickname ?? kUnknownCalendarPerson;
}
/// 남의 일정의 대상 — 내 반려동물 목록에 없으므로 서버가 이름·사진을 함께 준다
class EventTarget {
const EventTarget({required this.id, this.name, this.photoPath, this.isSelf = false});
final String id;
final String? name;
final String? photoPath;
/// 일정을 만든 사람 본인 (대상 "나")
final bool isSelf;
}
/// 내 함께 관리 상태 — 내 초대 코드와 연동된 사람들 (나 먼저)
class CalendarSharing {
const CalendarSharing({
required this.inviteCode,
required this.linked,
required this.members,
this.maxMembers = kMaxCalendarMembers,
});
/// 내 초대 코드 — 받은 사람이 입력하면 내 달력에 연동된다
final String inviteCode;
/// 다른 사람과 연동되어 있는지 (2명 이상)
final bool linked;
final List<CalendarPerson> members;
final int maxMembers;
bool get isFull => members.length >= maxMembers;
}
/// 받은 초대 코드로 연동하기 전에 확인하는 정보
class CalendarInvitation {
const CalendarInvitation({
required this.code,
required this.ownerNickname,
required this.memberCount,
required this.maxMembers,
required this.isMine,
required this.isMember,
});
final String code;
final String? ownerNickname;
/// 코드 주인이 함께 쓰는 사람 수 (혼자면 1)
final int memberCount;
final int maxMembers;
/// 내 초대 코드인지
final bool isMine;
/// 이미 같은 달력을 쓰는 사람인지
final bool isMember;
bool get isFull => memberCount >= maxMembers;
}
/// 초대 코드 앞머리 (서버가 `RUP-` + 6자로 만든다 — 코드만 알면 달력에 들어오므로 짐작하기 어렵게)
const String kCalendarCodePrefix = 'RUP-';
/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽는다
String calendarInviteLink(String code) => 'rup://calendar-invite/$code';
final RegExp _kCodeBody = RegExp(r'^[A-Z0-9]{6}$');
/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://calendar-invite/RUP-8K2D7Q)·
/// 코드(RUP-8K2D7Q, 소문자도)·앞머리 없이 쓴 6자(8K2D7Q) 모두 받는다. 초대 코드가 아니면 null
String? parseCalendarCode(String raw) {
var text = raw.trim();
const link = 'rup://calendar-invite/';
if (text.startsWith(link)) text = text.substring(link.length);
text = text.split('?').first.trim().toUpperCase().replaceAll(' ', '');
if (text.startsWith(kCalendarCodePrefix)) text = text.substring(kCalendarCodePrefix.length);
return _kCodeBody.hasMatch(text) ? '$kCalendarCodePrefix$text' : null;
}
@@ -3,6 +3,7 @@ import 'dart:ui' show Color;
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
export 'package:tma_app/features/pet/domain/pet_target.dart' show kSelfTargetId;
@@ -19,6 +20,8 @@ class EventDetails {
this.end,
this.place,
this.labelColor,
this.owner,
this.sharedTargets = const [],
});
final String title;
@@ -43,7 +46,15 @@ class EventDetails {
/// 달력 점·목록 띠에 쓰는 색
Color get color => labelColor ?? category.color;
EventDetails copyWith({List<String>? targetIds}) => EventDetails(
/// 함께 관리에서 다른 사람이 만든 일정이면 그 사람 (내 일정은 null) — 서버가 알려 주는 보기용 값이라 저장하지 않는다
final CalendarPerson? owner;
/// 남의 일정의 대상 이름·사진 (내 일정은 비어 있다 — 내 반려동물 목록으로 그린다)
final List<EventTarget> sharedTargets;
bool get isMine => owner == null;
EventDetails copyWith({List<String>? targetIds, CalendarPerson? owner, List<EventTarget>? sharedTargets}) => EventDetails(
title: title,
kind: kind,
category: category,
@@ -52,6 +63,8 @@ class EventDetails {
end: end,
place: place,
labelColor: labelColor,
owner: owner ?? this.owner,
sharedTargets: sharedTargets ?? this.sharedTargets,
);
Map<String, Object?> toJson() => {
@@ -131,6 +131,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
bool get _editing => widget.editing != null;
/// 함께 관리에서 다른 사람이 만든 일정 — 대상(그 사람의 아이)과 반복·기간 구성은 만든 사람만 바꾼다.
/// 바꾸면 내 일정으로 새로 만들어지므로 이름·분류·시간·장소·색만 고친다
bool get _foreign => !(_orig?.isMine ?? true);
static DateTime _addDays(DateTime d, int days) => DateTime(d.year, d.month, d.day + days);
@override
@@ -375,28 +379,38 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
onBack: () => context.pop(),
),
const SizedBox(height: 6),
EditorField(
label: '누구의 일정인가요?',
hint: '여러 마리 선택 가능',
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Row(
children: [
for (final (i, id) in [kSelfTargetId, ...widget.pets.map((p) => p.id)].indexed) ...[
if (i > 0) const SizedBox(width: 10),
TargetChip(
targetId: id,
name: names[id] ?? '',
selected: _targets.contains(id),
onTap: () => _toggleTarget(id),
),
if (_foreign)
EditorField(
label: '누구의 일정인가요?',
hint: '${_orig!.owner!.name}님이 만든 일정',
child: Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: EventMeta(details: _orig, avatarSize: 22, gap: AppSpacing.sm),
),
)
else
EditorField(
label: '누구의 일정인가요?',
hint: '여러 마리 선택 가능',
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Row(
children: [
for (final (i, id) in [kSelfTargetId, ...widget.pets.map((p) => p.id)].indexed) ...[
if (i > 0) const SizedBox(width: 10),
TargetChip(
targetId: id,
name: names[id] ?? '',
selected: _targets.contains(id),
onTap: () => _toggleTarget(id),
),
],
],
],
),
),
),
),
EditorField(
label: '예약 일정',
hint: '병원·미용·검진 등',
@@ -435,34 +449,36 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
),
),
EditorField(child: _detailsBox()),
EditorField(
child: ExpandPanel(
icon: RupIconName.repeat,
title: '반복 일정',
subtitle: _repeat == null ? '반복되는 일정에 활용해보세요' : '${_repeat!.label} 반복',
on: _repeat != null,
onChanged: (v) => setState(() {
_repeat = v ? kInitialRepeat : null;
_customCycle = false;
}),
child: RepeatOptions(
spec: _repeat ?? kInitialRepeat,
custom: _customCycle,
onSpec: (s) => setState(() => _repeat = s),
onCustom: (v) => setState(() => _customCycle = v),
if (!_foreign) ...[
EditorField(
child: ExpandPanel(
icon: RupIconName.repeat,
title: '반복 일정',
subtitle: _repeat == null ? '반복되는 일정에 활용해보세요' : '${_repeat!.label} 반복',
on: _repeat != null,
onChanged: (v) => setState(() {
_repeat = v ? kInitialRepeat : null;
_customCycle = false;
}),
child: RepeatOptions(
spec: _repeat ?? kInitialRepeat,
custom: _customCycle,
onSpec: (s) => setState(() => _repeat = s),
onCustom: (v) => setState(() => _customCycle = v),
),
),
),
),
EditorField(
child: ExpandPanel(
icon: RupIconName.pill,
title: '기간 설정',
subtitle: '시작일과 종료일이 있는 일정에 활용해보세요',
on: _periodOn,
onChanged: (v) => setState(() => _periodOn = v),
child: _periodBody(),
EditorField(
child: ExpandPanel(
icon: RupIconName.pill,
title: '기간 설정',
subtitle: '시작일과 종료일이 있는 일정에 활용해보세요',
on: _periodOn,
onChanged: (v) => setState(() => _periodOn = v),
child: _periodBody(),
),
),
),
],
EditorField(
label: '라벨 색',
hint: '달력에 표시되는 색',
@@ -0,0 +1,570 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/storage/album_saver.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart';
import 'package:tma_app/features/schedule/presentation/widgets/calendar_link_flow.dart';
import 'package:tma_app/shared/widgets/capture_boundary.dart';
import 'package:tma_app/shared/widgets/dashed_border_box.dart';
import 'package:tma_app/shared/widgets/heart_toggle.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kMemberAvatar = 42;
const double _kInviteQr = 130;
/// QR 상자 높이 (여백 11 × 2 + 테두리 1 × 2) — 오른쪽 코드·버튼 칸이 이 높이를 나눠 쓴다
const double _kInviteRowHeight = _kInviteQr + 11 * 2 + 2;
const double _kSheetQr = 196;
/// 펼쳐지는 영역 (시안 max-height .45s cubic-bezier(.4,0,.2,1))
const Duration _kExpand = Duration(milliseconds: 450);
TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color, {double height = AppTypography.normalHeight}) =>
Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: height);
/// 함께 관리 — 일정 연동 켜기·끄기, 연동된 사람, 내 초대 코드·QR, 받은 코드로 연동
class ShareCalendarPage extends ConsumerWidget {
const ShareCalendarPage({super.key});
/// 연동 스위치 — 끄는데 연동되어 있으면 다른 사람의 일정이 사라지므로 먼저 묻는다
Future<void> _toggle(BuildContext context, WidgetRef ref, CalendarSharing sharing, bool on) async {
final toast = ref.read(toastProvider.notifier);
final sharingOn = ref.read(calendarSharingOnProvider.notifier);
if (on) {
await sharingOn.set(on: true);
toast.show('함께 관리를 켰어요', icon: RupIconName.users);
return;
}
if (!sharing.linked) {
await sharingOn.set(on: false);
return;
}
if (!await confirmCalendarUnlink(context, sharing.members)) return;
if (!await succeeded(ref.read(calendarSharingProvider.notifier).leave())) return;
await sharingOn.set(on: false);
toast.show('연동을 해제했어요', icon: RupIconName.logout);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharing = ref.watch(calendarSharingProvider).value;
final linked = sharing?.linked ?? false;
final on = linked || ref.watch(calendarSharingOnProvider);
return Scaffold(
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SubPageHeader(
title: '함께 관리',
subtitle: '가족·친구와 같은 달력을 함께 써요',
onBack: () => context.pop(),
),
_ToggleCard(
on: on,
onChanged: sharing == null ? null : (v) => unawaited(_toggle(context, ref, sharing, v)),
),
if (!on) const _OffHint(),
// 켜면 펼쳐진다
AnimatedSize(
duration: _kExpand,
curve: const Cubic(.4, 0, .2, 1),
alignment: Alignment.topCenter,
child: on && sharing != null ? _OnBody(sharing: sharing) : const SizedBox(width: double.infinity),
),
],
),
),
),
);
}
}
/// 일정 연동 켜기·끄기 카드
class _ToggleCard extends StatelessWidget {
const _ToggleCard({required this.on, required this.onChanged});
final bool on;
final ValueChanged<bool>? onChanged;
@override
Widget build(BuildContext context) {
return RupCard(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 38,
height: 38,
decoration: BoxDecoration(
color: on ? AppColors.primary50 : AppColors.line2,
borderRadius: BorderRadius.circular(AppSpacing.md),
),
alignment: Alignment.center,
child: RupIcon(RupIconName.users, size: 20, stroke: 2.2, color: on ? AppColors.primary600 : AppColors.muted),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('일정 연동', style: _ui(context, 15.5, FontWeight.w800, AppColors.ink)),
const SizedBox(height: 2),
Text(
on ? '가족·친구와 같은 달력을 공유 중이에요' : '켜면 가족·친구와 일정을 함께 관리해요',
style: _ui(context, 12.5, FontWeight.w400, AppColors.muted),
),
],
),
),
HeartToggle(value: on, label: '일정 연동', onChanged: onChanged ?? (_) {}),
],
),
);
}
}
/// 꺼졌을 때 안내
class _OffHint extends StatelessWidget {
const _OffHint();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xxl, 36, AppSpacing.xxl, 10),
child: Column(
children: [
const RupIcon(RupIconName.users, size: 42, stroke: 1.8, color: AppColors.line),
const SizedBox(height: 10),
Text(
'연동을 켜면 초대 코드·QR로\n가족·친구와 같은 달력을 함께 쓸 수 있어요.',
textAlign: TextAlign.center,
style: _ui(context, 13, FontWeight.w400, AppColors.muted, height: 1.6),
),
],
),
);
}
}
/// 켜졌을 때 — 안내 띠 · 연동된 사람 · 일정 연동하기
class _OnBody extends ConsumerWidget {
const _OnBody({required this.sharing});
final CalendarSharing sharing;
@override
Widget build(BuildContext context, WidgetRef ref) {
final members = sharing.members;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 14),
const _Banner(),
const SizedBox(height: 22),
CountSectionTitle(title: '연동된 사람', count: '${members.length}/${sharing.maxMembers}'),
const SizedBox(height: AppSpacing.md),
RupCard(
child: Column(
children: [
for (final (i, m) in members.indexed) _MemberRow(person: m, first: i == 0),
if (!sharing.isFull) _InviteSlot(left: sharing.maxMembers - members.length),
],
),
),
if (!sharing.isFull) ...[
const SizedBox(height: 24),
const CountSectionTitle(title: '일정 연동하기'),
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, AppSpacing.xs, AppSpacing.lg),
child: Text(
'QR코드 혹은 초대코드로 일정을 연동해보세요.',
style: _ui(context, 13, FontWeight.w400, AppColors.muted, height: 1.55),
),
),
// QR 은 자기 크기를 미리 알려 주지 못해(LayoutBuilder) 높이를 정해 둔다
SizedBox(
height: _kInviteRowHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
button: true,
label: '초대 QR 크게 보기',
child: GestureDetector(
onTap: () => unawaited(_showQrSheet(context, ref, sharing.inviteCode)),
child: _InviteQr(code: sharing.inviteCode),
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _CodeCard(code: sharing.inviteCode)),
const SizedBox(height: 10),
_LinkByCodeButton(onTap: () => unawaited(startCalendarLink(context, ref))),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 14, AppSpacing.xs, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: RupIcon(RupIconName.alert, size: 15, stroke: 2.2, color: AppColors.primary),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text.rich(
const TextSpan(children: [
TextSpan(text: '연동을 하게 되면 '),
TextSpan(text: '연동된 사람과 모든 일정이 공유됩니다.', style: TextStyle(fontWeight: FontWeight.w700)),
]),
style: _ui(context, 12.5, FontWeight.w400, AppColors.ink2, height: 1.55),
),
),
],
),
),
],
],
);
}
}
/// "같은 달력, 같은 일정" 안내 띠 (주황 그라데이션)
class _Banner extends StatelessWidget {
const _Banner();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 17),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppSpacing.lg),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, Color.lerp(AppColors.primary, Colors.white, .22)!],
),
boxShadow: AppShadows.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(color: const Color(0x38FFFFFF), borderRadius: BorderRadius.circular(11)),
alignment: Alignment.center,
child: const RupIcon(RupIconName.users, size: 19, stroke: 2.2, color: Colors.white),
),
const SizedBox(width: 9),
Text('같은 달력, 같은 일정', style: _ui(context, 16, FontWeight.w800, Colors.white)),
],
),
const SizedBox(height: 9),
Text.rich(
const TextSpan(children: [
TextSpan(text: '연동하면 모두가 '),
TextSpan(text: '같은 달력', style: TextStyle(fontWeight: FontWeight.w700)),
TextSpan(text: '을 보고, 누구나 일정을 추가·수정할 수 있어요. '),
TextSpan(text: '최대 $kMaxCalendarMembers명', style: TextStyle(fontWeight: FontWeight.w700)),
TextSpan(text: '까지 연동돼요.'),
]),
style: _ui(context, 13, FontWeight.w400, const Color(0xF0FFFFFF), height: 1.65),
),
],
),
);
}
}
class _MemberRow extends StatelessWidget {
const _MemberRow({required this.person, required this.first});
final CalendarPerson person;
final bool first;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13),
decoration: BoxDecoration(border: first ? null : const Border(top: BorderSide(color: AppColors.line2))),
child: Row(
children: [
PersonAvatar(size: _kMemberAvatar, photoPath: person.photoPath),
const SizedBox(width: 13),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
person.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _ui(context, 15.5, FontWeight.w700, AppColors.ink),
),
),
if (person.isMe) ...[
const SizedBox(width: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: 2),
decoration: BoxDecoration(color: AppColors.primary50, borderRadius: BorderRadius.circular(AppRadius.pill)),
child: Text('나', style: _ui(context, 11, FontWeight.w700, AppColors.primary600)),
),
],
],
),
),
if (!person.isMe) const RupIcon(RupIconName.link, size: 18, stroke: 2.2, color: AppColors.primary),
],
),
);
}
}
/// 빈 자리 — "N명 더 초대할 수 있어요"
class _InviteSlot extends StatelessWidget {
const _InviteSlot({required this.left});
final int left;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13),
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))),
child: Row(
children: [
const DashedBorderBox(
color: AppColors.line,
strokeWidth: 2,
radius: _kMemberAvatar / 2,
child: SizedBox.square(
dimension: _kMemberAvatar,
child: Center(child: RupIcon(RupIconName.plus, size: 20, stroke: 2.2, color: AppColors.muted)),
),
),
const SizedBox(width: 13),
Expanded(child: Text('$left명 더 초대할 수 있어요', style: _ui(context, 13.5, FontWeight.w600, AppColors.muted))),
],
),
);
}
}
/// 내 초대 QR — 가운데 발바닥 표시
class _InviteQr extends StatelessWidget {
const _InviteQr({required this.code, this.size = _kInviteQr, this.padding = 11, this.badge = 34});
final String code;
final double size;
final double padding;
final double badge;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(padding),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.line),
boxShadow: AppShadows.sm,
),
child: Stack(
alignment: Alignment.center,
children: [
QrImageView(
data: calendarInviteLink(code),
size: size,
padding: EdgeInsets.zero,
errorCorrectionLevel: QrErrorCorrectLevel.H,
semanticsLabel: '일정 연동 초대 QR 코드',
),
Container(
width: badge,
height: badge,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(badge * .32),
boxShadow: [BoxShadow(color: Colors.white, spreadRadius: badge / 8.5)],
),
alignment: Alignment.center,
child: RupIcon(RupIconName.paw, size: badge * .53, color: Colors.white),
),
],
),
);
}
}
/// 초대 코드 — 누르면 복사
class _CodeCard extends ConsumerWidget {
const _CodeCard({required this.code});
final String code;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Semantics(
button: true,
label: '초대 코드 복사',
child: GestureDetector(
onTap: () async {
final toast = ref.read(toastProvider.notifier);
await Clipboard.setData(ClipboardData(text: code));
toast.show('초대 코드를 복사했어요', icon: RupIconName.copy);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 11),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('초대 코드', style: _ui(context, 12, FontWeight.w700, AppColors.muted)),
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
code,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 19,
letterSpacing: 19 * .04,
height: AppTypography.displayNormalHeight,
color: AppColors.ink,
),
),
),
],
),
),
const SizedBox(width: 10),
const RupIcon(RupIconName.copy, size: 18, stroke: 2, color: AppColors.primary),
],
),
),
),
);
}
}
/// "코드로 연동" — 받은 코드로 다른 사람의 달력에 연동
class _LinkByCodeButton extends StatelessWidget {
const _LinkByCodeButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Pressable(
pressedScale: .98,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: Row(
children: [
const RupIcon(RupIconName.link, size: 18, stroke: 2, color: AppColors.primary),
const SizedBox(width: 10),
Expanded(child: Text('코드로 연동', style: _ui(context, 13.5, FontWeight.w700, AppColors.ink))),
const RupIcon(RupIconName.chevR, size: 16, color: AppColors.muted),
],
),
),
);
}
}
/// QR 코드로 초대 시트 — 크게 보여 주고 앨범에 저장할 수 있다
Future<void> _showQrSheet(BuildContext context, WidgetRef ref, String code) {
final capture = CaptureController();
final saver = ref.read(albumSaverProvider);
final toast = ref.read(toastProvider.notifier);
return showRupSheet<void>(
context,
title: 'QR 코드로 초대',
flushBottom: true,
builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'카메라로 스캔 후 연동하실 수 있어요.',
textAlign: TextAlign.center,
style: _ui(sheetContext, 13, FontWeight.w600, AppColors.ink2),
),
const SizedBox(height: AppSpacing.lg),
Center(
child: CaptureBoundary(
controller: capture,
child: _InviteQr(code: code, size: _kSheetQr, padding: 18, badge: 46),
),
),
const SizedBox(height: 2),
RupSheetBleedButton(
label: 'QR 이미지로 저장',
onPressed: () async {
final bytes = await capture.capturePng();
final saved = bytes != null && await saver.savePng(bytes, 'rup_일정연동_QR');
if (sheetContext.mounted) Navigator.of(sheetContext).pop();
toast.show(
saved ? 'QR 이미지를 앨범에 저장했어요' : '저장하지 못했어요. 사진 접근 권한을 확인해 주세요',
icon: saved ? RupIconName.download : RupIconName.alert,
);
},
),
],
),
);
}
@@ -0,0 +1,62 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/storage/preferences_provider.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/presentation/providers/schedule_providers.dart';
const String _kSharingOnKey = 'calendar.sharingOn.v1';
/// 함께 관리 — 내 초대 코드와 연동된 사람들 (기기 저장 모드는 null)
class CalendarSharingNotifier extends AsyncNotifier<CalendarSharing?> {
CalendarSharingRepository get _repo => ref.read(calendarSharingRepositoryProvider);
@override
Future<CalendarSharing?> build() {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
return ref.watch(calendarSharingRepositoryProvider).fetch();
}
void _set(CalendarSharing? next) {
if (ref.mounted) state = AsyncData(next);
}
/// 함께 관리를 쓸 수 있는지 (서버 모드)
bool get available => _repo.available;
Future<CalendarInvitation> previewInvitation(String code) => _repo.previewInvitation(code);
/// 받은 코드로 연동 — 함께 보는 일정이 바뀌므로 일정도 다시 읽는다
Future<void> join(String code) async {
_set(await _repo.join(code));
await ref.read(scheduleProvider.notifier).refresh();
}
/// 연동 해제 — 내 일정만 남는다
Future<void> leave() async {
await _repo.leave();
_set(await _repo.fetch());
await ref.read(scheduleProvider.notifier).refresh();
}
}
final calendarSharingProvider = AsyncNotifierProvider<CalendarSharingNotifier, CalendarSharing?>(
CalendarSharingNotifier.new,
);
/// 다른 사람과 연동되어 있는지 — 일정 목록에 만든 사람을 보여 줄지 정한다
final calendarLinkedProvider = Provider<bool>((ref) => ref.watch(calendarSharingProvider).value?.linked ?? false);
/// "일정 연동" 스위치 — 연동하지 않았을 때 초대 화면을 펼쳐 둘지 (기기에 둔다 · 시안 기본 켜짐)
class CalendarSharingOnNotifier extends Notifier<bool> {
@override
bool build() => ref.watch(sharedPreferencesProvider).getBool(_kSharingOnKey) ?? true;
Future<void> set({required bool on}) async {
state = on;
await ref.read(sharedPreferencesProvider).setBool(_kSharingOnKey, on);
}
}
final calendarSharingOnProvider = NotifierProvider<CalendarSharingOnNotifier, bool>(CalendarSharingOnNotifier.new);
@@ -73,6 +73,9 @@ class ScheduleNotifier extends AsyncNotifier<ScheduleBook> {
}
/// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다
/// 함께 관리를 연동·해제했을 때 — 보이는 일정이 바뀌므로 다시 읽는다 (불러오는 중 화면 없이 바꿔 끼운다)
Future<void> refresh() async => _set(await _repo.fetch());
Future<void> delete(ScheduleEvent target) async {
await _deleteTarget(target);
_set(_book.delete(target));
@@ -0,0 +1,402 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/api/error_lexicon.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
import 'package:tma_app/shared/widgets/qr_scanner_page.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 기기 저장 모드 안내 — 함께 관리는 다른 회원과 달력을 함께 쓰므로 서버가 있어야 한다
const String kCalendarSharingLocalOnly = '기기 저장 모드에서는 함께 관리를 쓸 수 없어요';
/// 초대 코드 입력칸 최대 길이 — RUP- + 6자 (시안은 4자 예시)
const int _kCodeMaxLength = 10;
/// 앞머리 없이 쓴 코드 길이 — 이보다 짧으면 버튼을 흐리게 둔다
const int _kMinCodeLength = 6;
/// 연동 해제 경고 색 (시안 #E0534F · 강조 #D1453F · 바탕 #FCEDEC)
const Color _kUnlink = Color(0xFFE0534F);
const Color _kUnlinkStrong = Color(0xFFD1453F);
const Color _kUnlinkBg = Color(0xFFFCEDEC);
/// 일정 연동 스캐너 문구
const QrScanCopy _kScanCopy = QrScanCopy(
title: '일정 연동',
hint: '연동할 사람의 초대 QR 코드를\n사각형 안에 비춰주세요.',
foundHint: '초대 QR을 찾았어요!',
manualLabel: '초대 코드 입력하기',
unavailableHint: '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 초대 코드를 입력해 주세요.',
);
/// 일정 연동 스캐너 — 초대 QR 을 비추면 연동할 사람을 불러온다
class CalendarLinkScannerPage extends StatelessWidget {
const CalendarLinkScannerPage({super.key});
@override
Widget build(BuildContext context) {
return QrScannerPage<CalendarInvitation>(
copy: _kScanCopy,
parse: parseCalendarCode,
// 초대 QR 이 아니면 서버가 못 찾았을 때와 같은 안내
invalidMessage: ErrorLexicon.getMessage('SCHED_001'),
load: (ref, code) => ref.read(calendarSharingProvider.notifier).previewInvitation(code),
);
}
}
/// 코드 입력 시트에서 고른 것 — QR 스캔으로 가거나, 코드로 찾은 초대
sealed class _LinkChoice {
const _LinkChoice();
}
final class _ChoseScan extends _LinkChoice {
const _ChoseScan();
}
final class _Found extends _LinkChoice {
const _Found(this.invitation);
final CalendarInvitation invitation;
}
/// 코드로 연동 — 코드 입력(또는 QR 스캔) → 확인 → 연동
Future<void> startCalendarLink(BuildContext context, WidgetRef ref) async {
final sharing = ref.read(calendarSharingProvider).value;
final toast = ref.read(toastProvider.notifier);
if (sharing == null) {
toast.show(kCalendarSharingLocalOnly, icon: RupIconName.users);
return;
}
// 이미 다른 사람과 연동되어 있으면 서버와 같은 안내로 멈춘다 (내 코드로 초대하는 것은 된다)
if (sharing.linked) {
toast.show(ErrorLexicon.getMessage('SCHED_004'), icon: RupIconName.users);
return;
}
CalendarInvitation? invitation;
while (invitation == null) {
if (!context.mounted) return;
final choice = await showRupSheet<_LinkChoice>(
context,
title: '초대 코드로 연동',
flushBottom: true,
builder: (_) => const _CodeBody(),
);
if (!context.mounted) return;
switch (choice) {
case null:
return;
case _Found(invitation: final found):
invitation = found;
case _ChoseScan():
final outcome = await context.push<ScanOutcome<CalendarInvitation>>(AppRoutes.calendarLinkScan);
switch (outcome) {
case ScanFound(:final value):
invitation = value;
case ScanTypeCode():
continue;
case null:
return;
}
}
}
if (context.mounted) await _confirmLink(context, ref, invitation);
}
/// 연동 전 확인 — 내 코드·이미 연동·가득 참은 서버와 같은 안내로 멈춘다
Future<void> _confirmLink(BuildContext context, WidgetRef ref, CalendarInvitation invitation) async {
final toast = ref.read(toastProvider.notifier);
final stop = invitation.isMine
? 'SCHED_003'
: invitation.isMember
? 'SCHED_005'
: invitation.isFull
? 'SCHED_002'
: null;
if (stop != null) {
toast.show(ErrorLexicon.getMessage(stop), icon: RupIconName.users);
return;
}
final ok = await showRupConfirm(
context,
title: '일정을 공유할까요?',
message: '일정을 연동하면 나의 모든 일정이 공유되어요.\n연동된 사람과 같은 달력을 함께 쓰게 돼요.',
confirmLabel: '공유하기',
layout: RupConfirmLayout.row,
icon: RupIconName.users,
iconColor: AppColors.primary600,
iconBackground: AppColors.primary50,
);
if (!ok) return;
if (!await succeeded(ref.read(calendarSharingProvider.notifier).join(invitation.code))) return;
toast.show('새로운 멤버와 연동했어요', icon: RupIconName.users);
}
/// 초대 코드로 연동 시트 — QR 스캔 / 코드 입력 + 안내 + 연동하기
class _CodeBody extends ConsumerStatefulWidget {
const _CodeBody();
@override
ConsumerState<_CodeBody> createState() => _CodeBodyState();
}
class _CodeBodyState extends ConsumerState<_CodeBody> {
final _code = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
// 입력이 생기면 버튼을 켠다
_code.addListener(() => setState(() {}));
}
@override
void dispose() {
_code.dispose();
super.dispose();
}
/// 코드 본문(6자) 이상 입력해야 연동할 수 있다
bool get _ready => _code.text.trim().length >= _kMinCodeLength && !_busy;
Future<void> _submit() async {
if (!_ready) return;
final code = parseCalendarCode(_code.text);
if (code == null) {
// 초대 코드 모양이 아니다 — 서버가 못 찾았을 때와 같은 안내
ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('SCHED_001'), icon: RupIconName.alert);
return;
}
setState(() => _busy = true);
try {
final invitation = await ref.read(calendarSharingProvider.notifier).previewInvitation(code);
if (mounted) Navigator.of(context).pop(_Found(invitation));
} catch (e) {
if (!isReportedApiFailure(e)) rethrow;
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
TextStyle ui(double size, FontWeight weight, Color color) =>
textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: AppTypography.normalHeight);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('받은 초대 코드를 입력해주세요', textAlign: TextAlign.center, style: ui(13, FontWeight.w600, AppColors.ink2)),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _code,
autofocus: true,
textAlign: TextAlign.center,
textInputAction: TextInputAction.done,
inputFormatters: [LengthLimitingTextInputFormatter(_kCodeMaxLength), _UpperCaseFormatter()],
onSubmitted: (_) => unawaited(_submit()),
cursorColor: AppColors.primary,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 24,
letterSpacing: 2.4,
height: AppTypography.displayNormalHeight,
color: AppColors.ink,
),
decoration: InputDecoration(
hintText: 'RUP-000000',
hintStyle: const TextStyle(fontFamily: AppFonts.display, fontSize: 24, letterSpacing: 2.4, color: AppColors.muted),
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.line, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(2, 13, 2, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: RupIcon(RupIconName.alert, size: 14, stroke: 2.2, color: AppColors.primary),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'연동을 하게 되면 연동된 사람과 모든 일정이 공유됩니다.',
style: ui(12, FontWeight.w400, AppColors.muted).copyWith(height: 1.5),
),
),
],
),
),
const SizedBox(height: 14),
// 받은 QR 을 비춰서도 연동할 수 있다
Center(
child: Pressable(
onTap: () => Navigator.of(context).pop(const _ChoseScan()),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs, horizontal: AppSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupIcon(RupIconName.qrcode, size: 16, color: AppColors.primary600),
const SizedBox(width: 6),
Text('QR 코드 스캔하기', style: ui(13, FontWeight.w700, AppColors.primary600)),
],
),
),
),
),
Opacity(
opacity: _code.text.trim().length >= _kMinCodeLength ? 1 : .45,
child: RupSheetBleedButton(label: '연동하기', onPressed: () => unawaited(_submit())),
),
],
),
);
}
}
/// 초대 코드는 대문자로 받는다 (시안 toUpperCase)
class _UpperCaseFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) =>
newValue.copyWith(text: newValue.text.toUpperCase());
}
/// 겹쳐 놓은 멤버 사진 (시안 MemberStack)
class CalendarMemberStack extends StatelessWidget {
const CalendarMemberStack({super.key, required this.members, required this.size, this.ring = 2.5});
final List<CalendarPerson> members;
final double size;
final double ring;
@override
Widget build(BuildContext context) {
final overlap = size * .32;
return SizedBox(
height: size,
width: members.isEmpty ? 0 : size + (members.length - 1) * (size - overlap),
child: Stack(
clipBehavior: Clip.none,
children: [
// 앞 사람이 위에 오도록 뒤에서부터 그린다
for (var i = members.length - 1; i >= 0; i--)
Positioned(
left: i * (size - overlap),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: AppColors.surface, spreadRadius: ring)],
),
child: PersonAvatar(size: size, photoPath: members[i].photoPath),
),
),
],
),
);
}
}
/// 연동 해제 확인 시트 — 해제하면 true
Future<bool> confirmCalendarUnlink(BuildContext context, List<CalendarPerson> members) async {
final ok = await showRupSheet<bool>(
context,
title: '연동을 해제할까요?',
builder: (sheetContext) {
final textTheme = Theme.of(sheetContext).textTheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs, bottom: AppSpacing.lg),
child: Center(child: CalendarMemberStack(members: members, size: 40)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14),
decoration: BoxDecoration(
color: _kUnlinkBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Color.lerp(_kUnlink, Colors.white, .68)!),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: RupIcon(RupIconName.alert, size: 18, stroke: 2.2, color: _kUnlink),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
const TextSpan(children: [
TextSpan(text: '연동 해제 시 '),
TextSpan(
text: '내가 쓴 일정을 제외한 일정은 모두 제거',
style: TextStyle(fontWeight: FontWeight.w700, color: _kUnlinkStrong),
),
TextSpan(text: '돼요. 함께 보던 다른 멤버의 일정은 더 이상 보이지 않아요.'),
]),
style: textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.6, color: AppColors.ink2),
),
),
],
),
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: RupButton(
label: '취소',
large: true,
variant: RupButtonVariant.soft,
onPressed: () => Navigator.of(sheetContext).pop(false),
),
),
const SizedBox(width: 10),
Expanded(
child: RupButton(
label: '연동 해제',
large: true,
variant: RupButtonVariant.alert,
onPressed: () => Navigator.of(sheetContext).pop(true),
),
),
],
),
],
);
},
);
return ok ?? false;
}
@@ -1,12 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.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/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
import 'package:tma_app/shared/widgets/photo_image.dart';
/// 하루 종일 일정의 시간 칸 표기
const String kAllDayLabel = '하루 종일';
@@ -39,37 +46,96 @@ class RupCheckBox extends StatelessWidget {
}
}
/// "콩이, 보리 · 동물병원" — 대상 + 장소
/// "콩이, 보리 · 동물병원" — 대상 + 장소.
/// 함께 관리 중이면 [showAuthor] 로 만든 사람을 오른쪽에 붙인다 (시안: 연동했을 때만)
class EventMeta extends ConsumerWidget {
const EventMeta({super.key, required this.details, this.avatarSize = 15, this.gap = 5});
const EventMeta({super.key, required this.details, this.avatarSize = 15, this.gap = 5, this.showAuthor = false});
final EventDetails details;
final double avatarSize;
final double gap;
final bool showAuthor;
@override
Widget build(BuildContext context, WidgetRef ref) {
final names = ref.watch(targetNamesProvider);
final mine = details.isMine;
// 남의 일정의 아이들은 내 반려동물 목록에 없다 — 서버가 준 이름으로 적는다
final names = mine
? ref.watch(targetNamesProvider)
: {for (final t in details.sharedTargets) t.id: t.name ?? kUnknownCalendarPerson};
final label = targetLabel(details.targetIds, names);
final place = details.place;
final muted = Theme.of(context).textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight);
final owner = details.owner;
return Row(
children: [
PetStack(targetIds: details.targetIds, size: avatarSize),
if (mine)
PetStack(targetIds: details.targetIds, size: avatarSize)
else
SharedTargetStack(targets: details.sharedTargets, size: avatarSize),
SizedBox(width: gap),
Expanded(
child: Text(
place == null || place.isEmpty ? label : '$label · $place',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
),
Expanded(child: Text(place == null || place.isEmpty ? label : '$label · $place', style: muted)),
if (showAuthor && ref.watch(calendarLinkedProvider)) ...[
const SizedBox(width: 6),
PersonAvatar(size: avatarSize, photoPath: owner == null ? ref.watch(profileProvider).photoPath : owner.photoPath),
const SizedBox(width: AppSpacing.xs),
Text(owner == null ? '나' : owner.name, style: muted.copyWith(fontSize: 11.5)),
],
],
);
}
}
/// 남의 일정의 대상 사진 — 조금씩 겹쳐 놓는다 (주인 본인이면 사람 사진)
class SharedTargetStack extends StatelessWidget {
const SharedTargetStack({super.key, required this.targets, required this.size, this.max = 3});
final List<EventTarget> targets;
final double size;
final int max;
@override
Widget build(BuildContext context) {
final shown = targets.take(max).toList();
final overlap = (size * .34).roundToDouble();
final placeholder = SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover);
return SizedBox(
height: size,
width: shown.isEmpty ? 0 : size + (shown.length - 1) * (size - overlap),
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = shown.length - 1; i >= 0; i--)
Positioned(
left: i * (size - overlap),
child: DecoratedBox(
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surface,
boxShadow: [BoxShadow(color: AppColors.surface, spreadRadius: 1.5)],
),
child: shown[i].isSelf
? PersonAvatar(size: size, photoPath: shown[i].photoPath, background: AppColors.primary50)
: Container(
width: size,
height: size,
decoration: const BoxDecoration(shape: BoxShape.circle, color: _kEmptyPetFill),
clipBehavior: Clip.antiAlias,
child: shown[i].photoPath == null
? placeholder
: PhotoImage(path: shown[i].photoPath!, fallback: placeholder),
),
),
),
],
),
);
}
}
/// 사진 없는 아이 바탕 — PetAvatar 와 같은 옅은 회색
const Color _kEmptyPetFill = Color(0x0A000000);
/// 분류 아이콘 상자 — 예약 일정에만 붙는다
class CategoryBadge extends StatelessWidget {
const CategoryBadge({super.key, required this.details, this.size = 32, this.iconSize = 17, this.radius = 9});
@@ -180,7 +246,7 @@ class EventTile extends StatelessWidget {
decorationColor: AppColors.ink,
),
),
EventMeta(details: d),
EventMeta(details: d, showAuthor: true),
],
),
),
@@ -9,7 +9,10 @@ import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
import 'package:tma_app/features/schedule/presentation/widgets/calendar_link_flow.dart';
import 'package:tma_app/features/schedule/presentation/widgets/event_tile.dart';
import 'package:tma_app/features/schedule/presentation/widgets/schedule_calendar.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
@@ -104,15 +107,25 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
}
}
/// 함께 관리 — 가족·친구와 달력 공유 (서버 연동 전이라 안내만)
/// 함께 관리 — 가족·친구와 달력 공유. 연동되어 있으면 함께 쓰는 사람들을 보여 준다
class _ShareCard extends ConsumerWidget {
const _ShareCard();
void _open(BuildContext context, WidgetRef ref) {
if (!ref.read(calendarSharingProvider.notifier).available) {
ref.read(toastProvider.notifier).show(kCalendarSharingLocalOnly, icon: RupIconName.users);
return;
}
unawaited(context.push(AppRoutes.shareCalendar));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final textTheme = Theme.of(context).textTheme;
final sharing = ref.watch(calendarSharingProvider).value;
final linked = sharing?.linked ?? false;
return RupCard(
onTap: () => ref.read(toastProvider.notifier).show('함께 관리는 곧 열릴 예정이에요', icon: RupIconName.users),
onTap: () => _open(context, ref),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
child: Row(
@@ -135,12 +148,16 @@ class _ShareCard extends ConsumerWidget {
),
const SizedBox(height: 1),
Text(
'가족·친구와 일정을 공유해보세요',
linked ? '${sharing!.members.length}명이 같은 달력을 함께 써요' : '가족·친구와 일정을 공유해보세요',
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight),
),
],
),
),
if (linked) ...[
CalendarMemberStack(members: sharing!.members.take(kMaxCalendarMembers).toList(), size: 26, ring: 2),
const SizedBox(width: AppSpacing.sm),
],
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
@@ -95,6 +95,44 @@ class SectionTitle extends StatelessWidget {
}
}
/// 섹션 제목 + 옆의 작은 수 — "멤버 2/4", "연동된 사람 1/4" (시안 h2 + 13/700 흐린 글)
class CountSectionTitle extends StatelessWidget {
const CountSectionTitle({super.key, required this.title, this.count});
final String title;
final String? count;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
title,
style: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight),
),
if (count != null) ...[
const SizedBox(width: 9),
Text(
count!,
style: textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
],
],
),
);
}
}
/// 빈 상태 문구 (시안 .rup-empty — 여백 40/20)
class EmptyMessage extends StatelessWidget {
const EmptyMessage(this.text, {super.key, this.padding});
+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);
});
}
@@ -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;