diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 0dc418e..3817bc7 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -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((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, diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 301c88e..19f09c9 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -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'; diff --git a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart index 8bc2dfa..3e7c8cc 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart @@ -194,7 +194,7 @@ class _DiarySettingsPageState extends ConsumerState { 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, diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart b/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart index d59e104..ec45203 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart @@ -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( diff --git a/flutter/lib/features/schedule/data/api_schedule_repository.dart b/flutter/lib/features/schedule/data/api_schedule_repository.dart index c2b0af4..3ebf992 100644 --- a/flutter/lib/features/schedule/data/api_schedule_repository.dart +++ b/flutter/lib/features/schedule/data/api_schedule_repository.dart @@ -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 json) => EventDetails( labelColor: colorFromHex(json['labelColor'] as String?), ); -ScheduleEvent scheduleEventFromApi(Map json) => ScheduleEvent( +/// 함께 관리 — 남이 만든 일정이면 만든 사람과 대상 요약을 붙인다 (내 일정·내 id 를 모르면 그대로) +EventDetails withOwnerFromApi(EventDetails details, Map json, String? myUserId) { + final ownerId = json['ownerId'] as String?; + if (ownerId == null || myUserId == null || ownerId == myUserId) return details; + final owner = json['owner'] as Map?; + 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>() ?? const >[]) + EventTarget( + id: t['id']! as String, + name: t['name'] as String?, + photoPath: serverFileRefOrNull(t['photoFileId'] as String?), + isSelf: t['id'] == kSelfTargetId, + ), + ], + ); +} + +ScheduleEvent scheduleEventFromApi(Map 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 json) => RepeatSpec( @@ -54,23 +79,30 @@ RepeatSpec repeatSpecFromApi(Map json) => RepeatSpec( n: (json['n'] as num?)?.toInt() ?? 1, ); -RepeatRule repeatRuleFromApi(Map json) => RepeatRule( +RepeatRule repeatRuleFromApi(Map json, {String? myUserId}) => RepeatRule( id: json['id']! as String, from: parseIsoDate(json['from']! as String), spec: repeatSpecFromApi(json['spec']! as Map), - details: eventDetailsFromApi(json['details']! as Map), + details: withOwnerFromApi(eventDetailsFromApi(json['details']! as Map), json, myUserId), ); Map _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 json) => scheduleEventFromApi(json, myUserId: _myUserId()); + + RepeatRule _rule(Map json) => repeatRuleFromApi(json, myUserId: _myUserId()); @override Future fetch() async { @@ -78,11 +110,11 @@ class ApiScheduleRepository implements ScheduleRepository { final done = {}; 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))); + created.addAll(response.data!.map((json) => _event(json as Map))); } 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 diff --git a/flutter/lib/features/schedule/data/calendar_sharing_repository.dart b/flutter/lib/features/schedule/data/calendar_sharing_repository.dart new file mode 100644 index 0000000..307c2df --- /dev/null +++ b/flutter/lib/features/schedule/data/calendar_sharing_repository.dart @@ -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 fetch(); + + /// 받은 초대 코드를 미리 본다 + Future previewInvitation(String code); + + /// 받은 초대 코드로 연동한다 + Future join(String code); + + /// 연동을 해제한다 — 내 일정은 남고 다른 사람의 일정은 더 이상 보이지 않는다 + Future leave(); +} + +/// 기기 저장 모드 — 함께 관리를 쓸 수 없다 (화면은 안내만 띄운다) +class UnavailableCalendarSharingRepository implements CalendarSharingRepository { + const UnavailableCalendarSharingRepository(); + + @override + bool get available => false; + + @override + Future fetch() async => null; + + @override + Future previewInvitation(String code) => throw StateError(_kLocalOnly); + + @override + Future join(String code) => throw StateError(_kLocalOnly); + + @override + Future leave() async {} +} + +const String _kLocalOnly = '함께 관리는 서버 모드에서만 쓸 수 있다'; + +CalendarSharing calendarSharingFromApi(Map 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>() ?? const >[]) + 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 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 fetch() async { + if (!_session.hasSession) return null; + final response = await _dio.get>(_kSharingPath); + return calendarSharingFromApi(response.data!); + } + + @override + Future previewInvitation(String code) async { + final response = await _dio.get>('$_kInvitationsPath/${Uri.encodeComponent(code)}'); + return calendarInvitationFromApi(code, response.data!); + } + + @override + Future join(String code) async { + final response = await _dio.post>(_kMembershipsPath, data: {'code': code}); + return calendarSharingFromApi(response.data!); + } + + @override + Future leave() => _dio.delete(_kMembershipPath); +} + +final calendarSharingRepositoryProvider = Provider((ref) { + return switch (ref.watch(dataModeProvider)) { + DataMode.local => const UnavailableCalendarSharingRepository(), + DataMode.api => ApiCalendarSharingRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)), + }; +}); diff --git a/flutter/lib/features/schedule/data/schedule_repository.dart b/flutter/lib/features/schedule/data/schedule_repository.dart index 54a1119..8eb57c0 100644 --- a/flutter/lib/features/schedule/data/schedule_repository.dart +++ b/flutter/lib/features/schedule/data/schedule_repository.dart @@ -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((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), + ), }; }); diff --git a/flutter/lib/features/schedule/domain/calendar_sharing.dart b/flutter/lib/features/schedule/domain/calendar_sharing.dart new file mode 100644 index 0000000..0c66ec9 --- /dev/null +++ b/flutter/lib/features/schedule/domain/calendar_sharing.dart @@ -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 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; +} diff --git a/flutter/lib/features/schedule/domain/schedule_event.dart b/flutter/lib/features/schedule/domain/schedule_event.dart index 16f46dd..295ac69 100644 --- a/flutter/lib/features/schedule/domain/schedule_event.dart +++ b/flutter/lib/features/schedule/domain/schedule_event.dart @@ -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? targetIds}) => EventDetails( + /// 함께 관리에서 다른 사람이 만든 일정이면 그 사람 (내 일정은 null) — 서버가 알려 주는 보기용 값이라 저장하지 않는다 + final CalendarPerson? owner; + + /// 남의 일정의 대상 이름·사진 (내 일정은 비어 있다 — 내 반려동물 목록으로 그린다) + final List sharedTargets; + + bool get isMine => owner == null; + + EventDetails copyWith({List? targetIds, CalendarPerson? owner, List? 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 toJson() => { diff --git a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart index c013fb6..9821a12 100644 --- a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart +++ b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart @@ -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: '달력에 표시되는 색', diff --git a/flutter/lib/features/schedule/presentation/pages/share_calendar_page.dart b/flutter/lib/features/schedule/presentation/pages/share_calendar_page.dart new file mode 100644 index 0000000..559ace2 --- /dev/null +++ b/flutter/lib/features/schedule/presentation/pages/share_calendar_page.dart @@ -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 _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? 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 _showQrSheet(BuildContext context, WidgetRef ref, String code) { + final capture = CaptureController(); + final saver = ref.read(albumSaverProvider); + final toast = ref.read(toastProvider.notifier); + return showRupSheet( + 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, + ); + }, + ), + ], + ), + ); +} diff --git a/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart b/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart new file mode 100644 index 0000000..44e28ef --- /dev/null +++ b/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart @@ -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 { + CalendarSharingRepository get _repo => ref.read(calendarSharingRepositoryProvider); + + @override + Future 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 previewInvitation(String code) => _repo.previewInvitation(code); + + /// 받은 코드로 연동 — 함께 보는 일정이 바뀌므로 일정도 다시 읽는다 + Future join(String code) async { + _set(await _repo.join(code)); + await ref.read(scheduleProvider.notifier).refresh(); + } + + /// 연동 해제 — 내 일정만 남는다 + Future leave() async { + await _repo.leave(); + _set(await _repo.fetch()); + await ref.read(scheduleProvider.notifier).refresh(); + } +} + +final calendarSharingProvider = AsyncNotifierProvider( + CalendarSharingNotifier.new, +); + +/// 다른 사람과 연동되어 있는지 — 일정 목록에 만든 사람을 보여 줄지 정한다 +final calendarLinkedProvider = Provider((ref) => ref.watch(calendarSharingProvider).value?.linked ?? false); + +/// "일정 연동" 스위치 — 연동하지 않았을 때 초대 화면을 펼쳐 둘지 (기기에 둔다 · 시안 기본 켜짐) +class CalendarSharingOnNotifier extends Notifier { + @override + bool build() => ref.watch(sharedPreferencesProvider).getBool(_kSharingOnKey) ?? true; + + Future set({required bool on}) async { + state = on; + await ref.read(sharedPreferencesProvider).setBool(_kSharingOnKey, on); + } +} + +final calendarSharingOnProvider = NotifierProvider(CalendarSharingOnNotifier.new); diff --git a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart index 976cbfe..d743bb4 100644 --- a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart +++ b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart @@ -73,6 +73,9 @@ class ScheduleNotifier extends AsyncNotifier { } /// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다 + /// 함께 관리를 연동·해제했을 때 — 보이는 일정이 바뀌므로 다시 읽는다 (불러오는 중 화면 없이 바꿔 끼운다) + Future refresh() async => _set(await _repo.fetch()); + Future delete(ScheduleEvent target) async { await _deleteTarget(target); _set(_book.delete(target)); diff --git a/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart b/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart new file mode 100644 index 0000000..61d83c0 --- /dev/null +++ b/flutter/lib/features/schedule/presentation/widgets/calendar_link_flow.dart @@ -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( + 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 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>(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 _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 _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 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 confirmCalendarUnlink(BuildContext context, List members) async { + final ok = await showRupSheet( + 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; +} diff --git a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart index 74fb9dc..8556ec2 100644 --- a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart +++ b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart @@ -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 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), ], ), ), diff --git a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart index 6f02750..c56ae9a 100644 --- a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart +++ b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart @@ -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 { } } -/// 함께 관리 — 가족·친구와 달력 공유 (서버 연동 전이라 안내만) +/// 함께 관리 — 가족·친구와 달력 공유. 연동되어 있으면 함께 쓰는 사람들을 보여 준다 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), ], ), diff --git a/flutter/lib/shared/widgets/section_title.dart b/flutter/lib/shared/widgets/section_title.dart index 31dca81..59fc47b 100644 --- a/flutter/lib/shared/widgets/section_title.dart +++ b/flutter/lib/shared/widgets/section_title.dart @@ -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}); diff --git a/flutter/test/api_calendar_sharing_test.dart b/flutter/test/api_calendar_sharing_test.dart new file mode 100644 index 0000000..1b3106a --- /dev/null +++ b/flutter/test/api_calendar_sharing_test.dart @@ -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 _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 _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, '초코'); + }); + }); +} diff --git a/flutter/test/calendar_sharing_flow_test.dart b/flutter/test/calendar_sharing_flow_test.dart new file mode 100644 index 0000000..aa8e6f5 --- /dev/null +++ b/flutter/test/calendar_sharing_flow_test.dart @@ -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 = []; + + @override + bool get available => true; + + @override + Future fetch() async => sharing; + + @override + Future previewInvitation(String code) async { + calls.add('preview $code'); + final found = invitation; + if (found == null) throw ApiException.fromCode('SCHED_001'); + return found; + } + + @override + Future join(String code) async { + calls.add('join $code'); + return sharing = _sharing(linked: true); + } + + @override + Future leave() async { + calls.add('leave'); + sharing = _sharing(); + } +} + +/// 다른 사람이 만든 일정 하나가 든 저장소 (기기 저장소는 만든 사람을 담지 않는다) +class _FakeScheduleRepo implements ScheduleRepository { + _FakeScheduleRepo(this.book); + + ScheduleBook book; + + @override + Future fetch() async => book; + + @override + Future> createEvents(List drafts) async => [ + for (final (i, d) in drafts.indexed) ScheduleEvent(id: 'new-$i', date: d.date, details: d.details), + ]; + + @override + Future updateEvent(ScheduleEvent event, EventDetails details) async => + event.copyWith(details: details.copyWith(owner: event.details.owner, sharedTargets: event.details.sharedTargets)); + + @override + Future deleteEvent(String eventId) async {} + + @override + Future createRule(DateTime from, RepeatSpec spec, EventDetails details) async => + RepeatRule(id: 'rule-new', spec: spec, from: from, details: details); + + @override + Future updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async => + rule.copyWith(spec: spec, details: details); + + @override + Future deleteRule(String ruleId) async {} + + @override + Future setDone(String occurrenceId, {required bool done}) async {} + + @override + Future 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 _openSharing(WidgetTester tester) async { + await tester.tap(find.text('함께 관리')); + await settle(tester); + expect(find.byType(ShareCalendarPage), findsOneWidget); +} + +Future _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); + }); +} diff --git a/flutter/test_live/api_contract_live_test.dart b/flutter/test_live/api_contract_live_test.dart index 94c01ca..de65d10 100644 --- a/flutter/test_live/api_contract_live_test.dart +++ b/flutter/test_live/api_contract_live_test.dart @@ -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;