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