Skip to content

Commit

Permalink
Merge pull request #2530 from acterglobal/ben-selective-calendar-sync
Browse files Browse the repository at this point in the history
Selective calendar sync
  • Loading branch information
gnunicorn authored Jan 30, 2025
2 parents fe2d735 + 9d63a97 commit d766ad4
Show file tree
Hide file tree
Showing 28 changed files with 977 additions and 123 deletions.
1 change: 1 addition & 0 deletions .changes/2530-selected-cal-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- You can now deselect calendar sync of specific spaces in their settings to exclude them from showing in your device calendar (on supported devices)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:acter/features/room/providers/user_settings_provider.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:logging/logging.dart';

final _log = Logger('a3::space::actions::activate');

Future<bool> setRoomSyncPreference(
WidgetRef ref,
L10n lang,
String roomId,
bool newValue,
) async {
try {
final settings = await ref.read(roomUserSettingsProvider(roomId).future);
return await settings.setIncludeCalSync(newValue);
} catch (e, s) {
_log.severe('Setting room calendar settings failed', e, s);
EasyLoading.showError(lang.error(e));
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
//ALL UPCOMING EVENTS
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/features/events/providers/event_type_provider.dart';
import 'package:acter/features/events/providers/event_providers.dart';
import 'package:acter/features/room/providers/user_settings_provider.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

typedef EventAndRsvp = ({CalendarEvent event, RsvpStatusTag? rsvp});

final shouldSyncRoomProvider =
FutureProvider.autoDispose.family<bool, String>((ref, roomId) async {
try {
final settings = await ref.watch(roomUserSettingsProvider(roomId).future);
return settings.includeCalSync();
} on RoomNotFound {
return false;
}
});

final eventsToSyncProvider = FutureProvider.autoDispose((ref) async {
// fetch all from all spaces
final allEventList = await ref.watch(allEventListProvider(null).future);
final upcomingAndOngoing = allEventList.where((event) {
final eventType = ref.watch(eventTypeProvider(event));
return eventType == EventFilters.upcoming ||
eventType == EventFilters.ongoing;
return (eventType == EventFilters.upcoming ||
eventType == EventFilters.ongoing);
});
final List<EventAndRsvp> toSync = [];

for (final event in upcomingAndOngoing) {
if (!await ref.watch(shouldSyncRoomProvider(event.roomIdStr()).future)) {
// we skip this one
continue;
}
final eventId = event.eventId().toString();
final myRsvpStatus = await ref.watch(myRsvpStatusProvider(eventId).future);
if (myRsvpStatus != RsvpStatusTag.No) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ class UtcNowNotifier extends StateNotifier<DateTime> {

@override
void dispose() {
// TODO: implement dispose
super.dispose();
_timer.cancel();
super.dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'dart:async';

import 'package:acter/common/providers/room_providers.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'
show UserRoomSettings;
import 'package:riverpod/riverpod.dart';

class AsyncUserRoomSettingsNotifier
extends AutoDisposeFamilyAsyncNotifier<UserRoomSettings, String> {
late Stream<bool> _listener;

Future<void> reload(String roomId) async {
state = AsyncData(await fetch(roomId));
}

Future<UserRoomSettings> fetch(String roomId) async {
final room = await ref.watch(maybeRoomProvider(roomId).future);
if (room == null) {
throw RoomNotFound();
}
return await room.userSettings();
}

@override
Future<UserRoomSettings> build(String arg) async {
final settings = await fetch(arg);
_listener = settings.subscribeStream(); // keep it resident in memory
_listener.forEach((e) async {
reload(arg);
});
return settings;
}
}
8 changes: 8 additions & 0 deletions app/lib/features/room/providers/user_settings_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:acter/features/room/providers/notifiers/user_settings_notifier.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final roomUserSettingsProvider = AsyncNotifierProvider.autoDispose
.family<AsyncUserRoomSettingsNotifier, UserRoomSettings, String>(
() => AsyncUserRoomSettingsNotifier(),
);
10 changes: 3 additions & 7 deletions app/lib/features/space/actions/has_seen_suggested.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/features/room/providers/user_settings_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';

final _log = Logger('a3::space::actions::suggested');

Future<void> markHasSeenSuggested(WidgetRef ref, String roomId) async {
final room = await ref.read(maybeRoomProvider(roomId).future);
if (room == null) {
_log.warning('Could’t mark $roomId suggested as seen. Room not found');
return;
}
try {
await room.setUserHasSeenSuggested(true);
final settings = await ref.read(roomUserSettingsProvider(roomId).future);
await settings.setHasSeenSuggested(true);
} catch (e, s) {
_log.severe('Could’t mark $roomId suggested failed', e, s);
}
Expand Down
9 changes: 3 additions & 6 deletions app/lib/features/space/providers/suggested_provider.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/common/providers/space_providers.dart';
import 'package:acter/features/room/providers/user_settings_provider.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
import 'package:logging/logging.dart';
import 'package:riverpod/riverpod.dart';
Expand All @@ -9,12 +9,9 @@ final _log = Logger('a3::space::suggested_provider');
// Whether or not to prompt the user about the suggested rooms.
final shouldShowSuggestedProvider =
FutureProvider.family<bool, String>((ref, spaceId) async {
final room = await ref.watch(maybeRoomProvider(spaceId).future);
if (room == null) {
return false;
}
try {
if (await room.userHasSeenSuggested()) {
final settings = await ref.read(roomUserSettingsProvider(spaceId).future);
if (settings.hasSeenSuggested()) {
return false;
}

Expand Down
23 changes: 23 additions & 0 deletions app/lib/features/space/settings/widgets/space_settings_menu.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import 'package:acter/common/extensions/acter_build_context.dart';
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/common/utils/routes.dart';
import 'package:acter/features/calendar_sync/actions/set_room_sync_preference.dart';
import 'package:acter/features/calendar_sync/providers/events_to_sync_provider.dart';
import 'package:acter/features/labs/model/labs_features.dart';
import 'package:acter/features/labs/providers/labs_providers.dart';
import 'package:acter_avatar/acter_avatar.dart';
import 'package:atlas_icons/atlas_icons.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -110,6 +114,25 @@ class SpaceSettingsMenu extends ConsumerWidget {
}
},
),
if (ref
.watch(isActiveProvider(LabsFeature.deviceCalendarSync)))
SettingsTile.switchTile(
initialValue: ref
.watch(shouldSyncRoomProvider(spaceId))
.valueOrNull ??
true,
leading: const Icon(Atlas.calendar_dots_thin),
onToggle: (newVal) {
setRoomSyncPreference(
ref,
L10n.of(context),
spaceId,
newVal,
);
},
title: Text(L10n.of(context).syncThisCalendarTitle),
description: Text(L10n.of(context).syncThisCalendarDesc),
),
],
),
SettingsSection(
Expand Down
2 changes: 2 additions & 0 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@
"@calendarSyncFeatureTitle": {},
"calendarSyncFeatureDesc": "Sync (tentative and accepted) events with device calendar (Android & iOS only)",
"@calendarSyncFeatureDesc": {},
"syncThisCalendarTitle": "Include in Calendar Sync",
"syncThisCalendarDesc": "Sync these events in the device calendar",
"camera": "Camera",
"@camera": {},
"cancel": "Cancel",
Expand Down
121 changes: 117 additions & 4 deletions app/test/features/calendar_sync/calendar_sync_test.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/features/calendar_sync/calendar_sync.dart';
import 'package:acter/features/calendar_sync/providers/events_to_sync_provider.dart';
import 'package:acter/features/datetime/providers/utc_now_provider.dart';
import 'package:acter/features/events/providers/event_providers.dart';
import 'package:acter/features/home/providers/client_providers.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
import 'package:device_calendar/device_calendar.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../../helpers/mock_a3sdk.dart';

import 'package:mocktail/mocktail.dart';

import '../../helpers/mock_event_providers.dart';
import '../../helpers/mock_room_providers.dart';

class MockDeviceCalendarPlugin extends Mock implements DeviceCalendarPlugin {}

void main() {
Expand All @@ -28,7 +37,7 @@ void main() {

const calendarId = '1';

final events = generateMockCalendarEvents(10);
final events = generateMockCalendarEvents(count: 10);
final firstTry =
events.map((e) => (event: e, rsvp: RsvpStatusTag.Maybe)).toList();
final secondTry =
Expand Down Expand Up @@ -69,13 +78,13 @@ void main() {
(await sharedPrefs()).getStringList(calendarSyncIdsKey);
expect(
mappedKeys,
List.generate(10, (idx) => 'event-$idx-id=resultKey-$idx'),
List.generate(10, (idx) => 'null-event-$idx-id=resultKey-$idx'),
);
});
});
group('Calendar Event Updater tests', () {
testWidgets('basics update fine', (tester) async {
final events = generateMockCalendarEvents(10);
final events = generateMockCalendarEvents(count: 10);

final event = Event('1');
for (final mock in events) {
Expand All @@ -87,7 +96,7 @@ void main() {
}
});
testWidgets('status and reminders are set correctly', (tester) async {
final mockEvent = generateMockCalendarEvents(1).first;
final mockEvent = generateMockCalendarEvents(count: 1).first;
final targetEvent = Event('1');

// yes add reminder
Expand Down Expand Up @@ -115,4 +124,108 @@ void main() {
expect(targetEvent.reminders?.first.minutes, 10);
});
});

group('Calendar Sync Provider test', () {
testWidgets('by default sync all', (tester) async {
SharedPreferences.setMockInitialValues({}); // no values set yet.

final roomAEvents = generateMockCalendarEvents(count: 5, roomId: 'roomA');
final roomBEvents = generateMockCalendarEvents(count: 5, roomId: 'roomB');
final roomCEvents = generateMockCalendarEvents(count: 5, roomId: 'roomC');
final List<MockCalendarEvent> events = [];
final client = MockClient();
final mockRoom = MockRoom();
final Stream<bool> updateSteam = Stream.empty();
when(() => client.subscribeModelObjectsStream(any(), any()))
.thenAnswer((a) => updateSteam);
when(() => mockRoom.userSettings())
.thenAnswer((_) async => MockRoomUserSettings());
events.addAll(roomAEvents);
events.addAll(roomBEvents);
events.addAll(roomCEvents);

final container = ProviderContainer(
overrides: [
utcNowProvider.overrideWith((ref) => MockUtcNowNotifier()),
allEventListProvider.overrideWith((r, a) => events),
alwaysClientProvider.overrideWith((ref) => client),
maybeRoomProvider.overrideWith(
() => MockAlwaysTheSameRoomNotifier(
room: mockRoom,
),
),
calendarEventProvider.overrideWith(
() => MockFindAsyncCalendarEventNotifier(events: events),
),
],
);
addTearDown(container.dispose);

// keeping the provider alive for the tests
// ignore: unused_local_variable
final subscription = container.listen(eventsToSyncProvider, (a, b) {});

final state = await container.read(eventsToSyncProvider.future);
expect(
state.length,
events.length,
);
});
testWidgets('excluded rooms are excluded', (tester) async {
SharedPreferences.setMockInitialValues({}); // no values set yet.

final roomAEvents = generateMockCalendarEvents(count: 5, roomId: 'roomA');
final roomBEvents = generateMockCalendarEvents(count: 5, roomId: 'roomB');
final roomCEvents = generateMockCalendarEvents(count: 5, roomId: 'roomC');
final List<MockCalendarEvent> events = [];
final roomA = MockRoom();
final roomB = MockRoom();
final roomC = MockRoom();
when(() => roomA.userSettings())
.thenAnswer((_) async => MockRoomUserSettings());
when(() => roomB.userSettings()).thenAnswer(
// b will be ignored
(_) async => MockRoomUserSettings(include_cal_sync: false),
);
when(() => roomC.userSettings())
.thenAnswer((_) async => MockRoomUserSettings());
final client = MockClient();
final Stream<bool> updateSteam = Stream.empty();
when(() => client.subscribeModelObjectsStream(any(), any()))
.thenAnswer((a) => updateSteam);
events.addAll(roomAEvents);
events.addAll(roomBEvents);
events.addAll(roomCEvents);
final container = ProviderContainer(
overrides: [
utcNowProvider.overrideWith((ref) => MockUtcNowNotifier()),
allEventListProvider.overrideWith((r, a) => events),
alwaysClientProvider.overrideWith((ref) => client),
maybeRoomProvider.overrideWith(
() => MockAsyncMaybeRoomNotifier(
items: {'roomA': roomA, 'roomB': roomB, 'roomC': roomC},
),
),
calendarEventProvider.overrideWith(
() => MockFindAsyncCalendarEventNotifier(events: events),
),
],
);
addTearDown(container.dispose);

// keeping the provider alive for the tests
// ignore: unused_local_variable
final subscription = container.listen(eventsToSyncProvider, (a, b) {});

final state = await container.read(eventsToSyncProvider.future);
expect(
state.length,
10,
);
expect(
state.map((e) => e.event.roomIdStr()).toSet(),
{'roomA', 'roomC'},
);
});
});
}
Loading

0 comments on commit d766ad4

Please sign in to comment.