diff --git a/.changes/2072-calendar-sync.md b/.changes/2072-calendar-sync.md
new file mode 100644
index 000000000000..403380bcd985
--- /dev/null
+++ b/.changes/2072-calendar-sync.md
@@ -0,0 +1 @@
+- [New Labs] You can now ask Acter to sync the events you haven't declined yet to your system calendar (on certain devices) including a reminder. As of now, this is behind a labs-feature flag you need to activate manually while we are rolling this out.
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index 675ddf1aff55..32b4ca4f3b0a 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
diff --git a/app/integration_test/tests/bug_reporter.dart b/app/integration_test/tests/bug_reporter.dart
index c2f6d8258eb9..b4f1ea8064e2 100644
--- a/app/integration_test/tests/bug_reporter.dart
+++ b/app/integration_test/tests/bug_reporter.dart
@@ -1,6 +1,6 @@
import 'package:acter/features/bug_report/pages/bug_report_page.dart';
import 'package:acter/features/home/data/keys.dart';
-import 'package:acter/features/home/pages/home_shell.dart';
+import 'package:acter/config/app_shell.dart';
import 'package:acter/features/search/model/keys.dart';
import 'package:acter/router/router.dart';
import 'package:convenient_test_dev/convenient_test_dev.dart';
@@ -220,7 +220,7 @@ void bugReporterTests() {
final prevReports = await latestReported();
// totally clean
await t.freshAccount();
- final HomeShellState home = t.tester.state(find.byKey(homeShellKey));
+ final AppShellState home = t.tester.state(find.byKey(appShellKey));
// as if we shaked
openBugReport(home.context);
diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock
index c8bca963682f..21e7fe32b763 100644
--- a/app/ios/Podfile.lock
+++ b/app/ios/Podfile.lock
@@ -6,6 +6,8 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
+ - device_calendar (0.0.1):
+ - Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.4):
@@ -86,6 +88,8 @@ PODS:
- Flutter
- media_kit_video (0.0.1):
- Flutter
+ - open_filex (0.0.2):
+ - Flutter
- package_info (0.0.1):
- Flutter
- package_info_plus (0.4.5):
@@ -105,6 +109,11 @@ PODS:
- SDWebImage/Core (5.13.0)
- sensors_plus (0.0.1):
- Flutter
+ - Sentry/HybridSDK (8.30.1)
+ - sentry_flutter (8.4.0):
+ - Flutter
+ - FlutterMacOS
+ - Sentry/HybridSDK (= 8.30.1)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -128,6 +137,7 @@ DEPENDENCIES:
- acter_flutter_sdk (from `.symlinks/plugins/acter_flutter_sdk/ios`)
- app_settings (from `.symlinks/plugins/app_settings/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
+ - device_calendar (from `.symlinks/plugins/device_calendar/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- fc_native_video_thumbnail (from `.symlinks/plugins/fc_native_video_thumbnail/darwin`)
@@ -143,6 +153,7 @@ DEPENDENCIES:
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
+ - open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info (from `.symlinks/plugins/package_info/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
@@ -150,6 +161,7 @@ DEPENDENCIES:
- push_ios (from `.symlinks/plugins/push_ios/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
+ - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
@@ -168,6 +180,7 @@ SPEC REPOS:
- GoogleUtilities
- PromisesObjC
- SDWebImage
+ - Sentry
- SwiftyGif
EXTERNAL SOURCES:
@@ -177,6 +190,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/app_settings/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/darwin"
+ device_calendar:
+ :path: ".symlinks/plugins/device_calendar/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
emoji_picker_flutter:
@@ -207,6 +222,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
+ open_filex:
+ :path: ".symlinks/plugins/open_filex/ios"
package_info:
:path: ".symlinks/plugins/package_info/ios"
package_info_plus:
@@ -221,6 +238,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sensors_plus:
:path: ".symlinks/plugins/sensors_plus/ios"
+ sentry_flutter:
+ :path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -240,6 +259,7 @@ SPEC CHECKSUMS:
acter_flutter_sdk: e60481171e46975418babb7d0ec8809eaacaaa03
app_settings: 017320c6a680cdc94c799949d95b84cb69389ebc
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
+ device_calendar: 9cb33f88a02e19652ec7b8b122ca778f751b1f7b
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
@@ -256,11 +276,12 @@ SPEC CHECKSUMS:
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
- integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
+ integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
+ open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
@@ -269,7 +290,9 @@ SPEC CHECKSUMS:
push_ios: 2bd1b4d3f782209da1f571db1250af236957e807
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 0327043dbb9533e75f2eff8445b3df0f2ceca6ac
- sensors_plus: 5717760720f7e6acd96fdbd75b7428f5ad755ec2
+ sensors_plus: 4b7c4bd9c9be2efbd575649368fe77ef09b429ad
+ Sentry: 514a3ea653886e9a48c6287d8b7bf05ec24bf3be
+ sentry_flutter: edc037f7af0dc1512d6c33a5c2c7c838bd0d6806
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
@@ -281,4 +304,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
-COCOAPODS: 1.14.3
+COCOAPODS: 1.15.2
diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift
index 70693e4a8c12..b6363034812b 100644
--- a/app/ios/Runner/AppDelegate.swift
+++ b/app/ios/Runner/AppDelegate.swift
@@ -1,7 +1,7 @@
import UIKit
import Flutter
-@UIApplicationMain
+@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist
index bf8aa2784dd0..dad2311f1821 100644
--- a/app/ios/Runner/Info.plist
+++ b/app/ios/Runner/Info.plist
@@ -34,6 +34,12 @@
Record messages with Acter to share via Chat or in Spaces with others.
NSPhotoLibraryUsageDescription
Allows you to select photos from your photo library to be uploaded into Acter and shared via Chat or in Spaces with others.
+ NSCalendarsUsageDescription
+ Access most functions for calendar viewing and editing.
+ NSContactsUsageDescription
+ Access contacts for event attendee editing.
+ NSCalendarsFullAccessUsageDescription
+ Access most functions for calendar viewing and editing.
UIApplicationSupportsIndirectInputEvents
UIBackgroundModes
diff --git a/app/lib/common/utils/feature_flagger.dart b/app/lib/common/utils/feature_flagger.dart
index d5ae5dd2065a..976f0445dca6 100644
--- a/app/lib/common/utils/feature_flagger.dart
+++ b/app/lib/common/utils/feature_flagger.dart
@@ -2,7 +2,6 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
-import 'package:riverpod/riverpod.dart';
final _log = Logger('a3::common::feature_flag');
@@ -81,18 +80,3 @@ class Features {
return Features(flags: newFlags, defaultOn: defaultOn);
}
}
-
-class FeaturesNotifier extends StateNotifier> {
- FeaturesNotifier(super.initState);
-
- // Let's the UI update the state of a flag
- void setActive(T f, bool active) {
- state = state.updateFlag(f, active);
- _log.info('updated ${state.toJson()}');
- }
-
- // Allow higher level to reset the features flagged
- void resetFeatures(List> features) {
- state = Features(flags: features, defaultOn: state.defaultOn);
- }
-}
diff --git a/app/lib/common/utils/utils.dart b/app/lib/common/utils/utils.dart
index 5e8560479a53..15931eec4259 100644
--- a/app/lib/common/utils/utils.dart
+++ b/app/lib/common/utils/utils.dart
@@ -407,6 +407,9 @@ enum LabsFeature {
// specific features
chatUnread,
+ // system features
+ deviceCalendarSync,
+
// not a lab anymore but needs to stay for backwards compat
tasks,
events,
diff --git a/app/lib/features/home/pages/home_shell.dart b/app/lib/config/app_shell.dart
similarity index 75%
rename from app/lib/features/home/pages/home_shell.dart
rename to app/lib/config/app_shell.dart
index a9692a584b31..43c27a9283b3 100644
--- a/app/lib/features/home/pages/home_shell.dart
+++ b/app/lib/config/app_shell.dart
@@ -1,4 +1,3 @@
-import 'package:acter/common/dialogs/logout_confirmation.dart';
import 'package:acter/config/notifications/init.dart';
import 'package:acter/common/providers/keyboard_visbility_provider.dart';
import 'package:acter/common/tutorial_dialogs/bottom_navigation_tutorials/bottom_navigation_tutorials.dart';
@@ -6,6 +5,8 @@ import 'package:acter/common/utils/constants.dart';
import 'package:acter/common/utils/device.dart';
import 'package:acter/common/utils/routes.dart';
import 'package:acter/common/utils/utils.dart';
+import 'package:acter/features/auth/pages/logged_out_screen.dart';
+import 'package:acter/features/calendar_sync/calendar_sync.dart';
import 'package:acter/features/cross_signing/widgets/cross_signing.dart';
import 'package:acter/features/home/providers/client_providers.dart';
import 'package:acter/features/home/providers/navigation.dart';
@@ -19,7 +20,6 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:screenshot/screenshot.dart';
@@ -54,16 +54,16 @@ Future openBugReport(BuildContext context) async {
}
}
-class HomeShell extends ConsumerStatefulWidget {
+class AppShell extends ConsumerStatefulWidget {
final StatefulNavigationShell navigationShell;
- const HomeShell({super.key, required this.navigationShell});
+ const AppShell({super.key, required this.navigationShell});
@override
- ConsumerState createState() => HomeShellState();
+ ConsumerState createState() => AppShellState();
}
-class HomeShellState extends ConsumerState {
+class AppShellState extends ConsumerState {
final GlobalKey _key =
GlobalKey(debugLabel: 'home shell scaffold');
late ShakeDetector detector;
@@ -71,12 +71,21 @@ class HomeShellState extends ConsumerState {
@override
void initState() {
super.initState();
- initShake();
- initNotifications();
+ _init();
+ }
+
+ Future _init() async {
+ // no wait goes there
Future.delayed(
const Duration(seconds: 1),
() => bottomNavigationTutorials(context: context),
);
+ initShake();
+
+ // these want to be sure to execute in order
+ await initNotifications();
+ // calendar sync
+ await initCalendarSync();
}
Future initShake() async {
@@ -113,64 +122,6 @@ class HomeShellState extends ConsumerState {
setupPushNotifications(client);
}
- Widget buildLoggedOutScreen(BuildContext context, bool softLogout) {
- // We have a special case
- return Scaffold(
- body: Container(
- margin: const EdgeInsets.only(top: kToolbarHeight),
- child: Center(
- child: Column(
- children: [
- Container(
- margin: const EdgeInsets.symmetric(vertical: 15),
- height: 100,
- width: 100,
- child: SvgPicture.asset(
- 'assets/images/undraw_access_denied_re_awnf.svg',
- ),
- ),
- Container(
- margin: const EdgeInsets.symmetric(vertical: 15),
- child: RichText(
- textAlign: TextAlign.center,
- text: TextSpan(
- text: L10n.of(context).access,
- style: Theme.of(context).textTheme.headlineLarge,
- children: [
- TextSpan(
- text: ' ${L10n.of(context).denied}',
- style: TextStyle(
- fontWeight: FontWeight.bold,
- color: Theme.of(context).colorScheme.error,
- fontSize: 32,
- ),
- ),
- ],
- ),
- ),
- ),
- Container(
- margin: const EdgeInsets.symmetric(vertical: 15),
- child: Text(
- L10n.of(context).yourSessionHasBeenTerminatedByServer,
- ),
- ),
- softLogout
- ? OutlinedButton(
- onPressed: onLoginAgain,
- child: Text(L10n.of(context).loginAgain),
- )
- : OutlinedButton(
- onPressed: onClearDB,
- child: Text(L10n.of(context).clearDBAndReLogin),
- ),
- ],
- ),
- ),
- ),
- );
- }
-
@override
Widget build(BuildContext context) {
// get platform of context.
@@ -187,7 +138,7 @@ class HomeShellState extends ConsumerState {
if (errorMsg != null) {
final softLogout = errorMsg == 'SoftLogout';
if (softLogout || errorMsg == 'Unauthorized') {
- return buildLoggedOutScreen(context, softLogout);
+ return LoggedOutScreen(softLogout: softLogout);
}
}
@@ -219,15 +170,6 @@ class HomeShellState extends ConsumerState {
);
}
- void onLoginAgain() {
- // FIXME: not yet properly supported
- context.goNamed(Routes.intro.name);
- }
-
- void onClearDB() {
- logoutConfirmationDialog(context, ref);
- }
-
Widget topNavigationWidget(BuildContext context) {
final hasFirstSynced =
ref.watch(syncStateProvider.select((v) => !v.initialSync));
diff --git a/app/lib/features/auth/pages/logged_out_screen.dart b/app/lib/features/auth/pages/logged_out_screen.dart
new file mode 100644
index 000000000000..902ca7589a4c
--- /dev/null
+++ b/app/lib/features/auth/pages/logged_out_screen.dart
@@ -0,0 +1,73 @@
+import 'package:acter/common/dialogs/logout_confirmation.dart';
+import 'package:acter/common/utils/routes.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/svg.dart';
+import 'package:go_router/go_router.dart';
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+
+class LoggedOutScreen extends ConsumerWidget {
+ final bool softLogout;
+ const LoggedOutScreen({
+ super.key,
+ required this.softLogout,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Scaffold(
+ body: Container(
+ margin: const EdgeInsets.only(top: kToolbarHeight),
+ child: Center(
+ child: Column(
+ children: [
+ Container(
+ margin: const EdgeInsets.symmetric(vertical: 15),
+ height: 100,
+ width: 100,
+ child: SvgPicture.asset(
+ 'assets/images/undraw_access_denied_re_awnf.svg',
+ ),
+ ),
+ Container(
+ margin: const EdgeInsets.symmetric(vertical: 15),
+ child: RichText(
+ textAlign: TextAlign.center,
+ text: TextSpan(
+ text: L10n.of(context).access,
+ style: Theme.of(context).textTheme.headlineLarge,
+ children: [
+ TextSpan(
+ text: ' ${L10n.of(context).denied}',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: Theme.of(context).colorScheme.error,
+ fontSize: 32,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ Container(
+ margin: const EdgeInsets.symmetric(vertical: 15),
+ child: Text(
+ L10n.of(context).yourSessionHasBeenTerminatedByServer,
+ ),
+ ),
+ softLogout
+ ? OutlinedButton(
+ onPressed: () => context.goNamed(Routes.intro.name),
+ child: Text(L10n.of(context).loginAgain),
+ )
+ : OutlinedButton(
+ onPressed: () => logoutConfirmationDialog(context, ref),
+ child: Text(L10n.of(context).clearDBAndReLogin),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/features/calendar_sync/calendar_sync.dart b/app/lib/features/calendar_sync/calendar_sync.dart
new file mode 100644
index 000000000000..7375031152f2
--- /dev/null
+++ b/app/lib/features/calendar_sync/calendar_sync.dart
@@ -0,0 +1,297 @@
+import 'dart:io';
+
+import 'package:acter/common/themes/colors/color_scheme.dart';
+import 'package:acter/common/utils/utils.dart';
+import 'package:acter/features/calendar_sync/providers/events_to_sync_provider.dart';
+import 'package:acter/features/settings/providers/settings_providers.dart';
+import 'package:acter/router/router.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk.dart';
+import 'package:device_calendar/device_calendar.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:logging/logging.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+final _log = Logger('a3::calendar_sync');
+
+final bool isSupportedPlatform = Platform.isAndroid || Platform.isIOS;
+typedef IdMapping = (String acterId, String localId);
+
+class CalendarSyncFailed extends Error {}
+
+const rejectionKey = 'rejected_calendar_sync';
+const calendarSyncKey = 'calendar_sync_id';
+const calendarSyncIdsKey = 'calendar_sync_ids';
+
+// internal state
+
+// ignore: unnecessary_late
+late DeviceCalendarPlugin deviceCalendar = DeviceCalendarPlugin();
+ProviderSubscription>>? _subscription;
+
+Future _isEnabled() async {
+ try {
+ return (await rootNavKey.currentContext!
+ .read(asyncIsActiveProvider(LabsFeature.deviceCalendarSync).future));
+ } catch (e, s) {
+ _log.severe('Reading current context failed', e, s);
+ return false;
+ }
+}
+
+T? _logError(Result result, String msg, {bool doThrow = false}) {
+ if (result.hasErrors) {
+ for (final err in result.errors) {
+ _log.severe('$msg ${err.errorCode}: ${err.errorMessage}');
+ }
+ if (doThrow) {
+ throw CalendarSyncFailed();
+ }
+ }
+ if (doThrow && result.data == null) {
+ throw CalendarSyncFailed();
+ }
+ return result.data;
+}
+
+Future initCalendarSync({bool ignoreRejection = false}) async {
+ if (!await _isEnabled()) {
+ _log.warning('Calendar Sync disabled');
+ return;
+ }
+ if (!isSupportedPlatform) {
+ _log.warning('Calendar Sync not available on this device');
+ return;
+ }
+ final SharedPreferences preferences = await sharedPrefs();
+
+ final hasPermission = await deviceCalendar.hasPermissions();
+
+ if (hasPermission.data == false) {
+ if (!ignoreRejection && (preferences.getBool(rejectionKey) ?? false)) {
+ _log.warning('user previously rejected calendar sync. quitting');
+ return;
+ }
+ final requesting = await deviceCalendar.requestPermissions();
+ if (requesting.data == false) {
+ await preferences.setBool(rejectionKey, true);
+ _log.warning('user rejected calendar sync. quitting');
+ return;
+ }
+ }
+ // FOR DEBUGGING CLEAR Acter CALENDARS VIA:
+ // await clearActerCalendars();
+
+ final calendarId = await _getOrCreateCalendar();
+ // clear if it existed before
+ _subscription?.close();
+ // start listening
+ _subscription =
+ ProviderScope.containerOf(rootNavKey.currentContext!, listen: true)
+ .listen(
+ eventsToSyncProvider,
+ (prev, next) async {
+ if (!next.hasValue) {
+ _log.info('ignoring state change without value');
+ return;
+ }
+ // FIXME: we probably want to debounce this ...
+ await _refreshCalendar(calendarId, next.valueOrNull ?? []);
+ },
+ fireImmediately: true,
+ );
+}
+
+Future _refreshCalendar(
+ String calendarId,
+ List events,
+) async {
+ final preferences = await sharedPrefs();
+ final Map currentLinks = {};
+ // reading the existing linking
+ for (final s in (preferences.getStringList(calendarSyncIdsKey) ?? [])) {
+ final parts = s.split('=');
+ currentLinks[parts.first] = parts.sublist(1).join('=');
+ }
+
+ final currentLinkKeys = currentLinks.values;
+ List foundEvents = [];
+ if (currentLinkKeys.isNotEmpty) {
+ final foundEventsResult = await deviceCalendar.retrieveEvents(
+ calendarId,
+ RetrieveEventsParams(eventIds: currentLinks.values.toList()),
+ );
+
+ foundEvents = List.of(
+ _logError(foundEventsResult, 'Failed to load calendar events') ?? [],
+ );
+ }
+
+ final newLinks = {};
+ final foundEventIds = [];
+ for (final eventAndRsvp in events) {
+ final calEvent = eventAndRsvp.event;
+ final rsvp = eventAndRsvp.rsvp;
+ final calEventId = calEvent.eventId().toString();
+ Event? localEvent;
+ if (currentLinks.containsKey(calEventId)) {
+ final localId = currentLinks[calEventId];
+ localEvent = foundEvents.cast().firstWhere(
+ (e) => e?.eventId == localId,
+ orElse: () => null,
+ );
+ }
+
+ if (localEvent == null) {
+ localEvent = Event(calendarId);
+ } else {
+ foundEventIds.add(localEvent.eventId);
+ }
+
+ localEvent = await _updateEventDetails(calEvent, rsvp, localEvent);
+ final localRequest = await deviceCalendar.createOrUpdateEvent(localEvent);
+ if (localRequest == null) {
+ _log.severe('Updating $calEventId failed. No response. skipping');
+ continue;
+ }
+ final resultData = _logError(localRequest, 'Updating $calEventId failed');
+ if (resultData != null) {
+ newLinks[calEventId] = resultData;
+ } else {
+ _log.warning('Updating $calEventId failed. no new id given');
+ if (localEvent.eventId != null) {
+ // assuming that all went fine...
+ // maybe this is usual?
+ newLinks[calEventId] = localEvent.eventId;
+ }
+ }
+ }
+ final newMapping =
+ newLinks.entries.map((m) => '${m.key}=${m.value}').toList();
+ _log.info('Storing new mapping: $newMapping');
+ // set our new mapping
+ await preferences.setStringList(
+ calendarSyncIdsKey,
+ newMapping,
+ );
+
+ // time to clean up events that we aren't tracking anymore
+ for (final toDelete in foundEvents
+ .where((e) => e.eventId != null && !foundEventIds.contains(e.eventId))) {
+ _log.info('Deleting event ${toDelete.eventId}');
+ _logError(
+ await deviceCalendar.deleteEvent(calendarId, toDelete.eventId),
+ 'Deleting local event $toDelete failed',
+ );
+ }
+}
+
+Future _updateEventDetails(
+ CalendarEvent acterEvent,
+ String? rsvp,
+ Event localEvent,
+) async {
+ localEvent.title = acterEvent.title();
+ localEvent.description = acterEvent.description()?.body();
+ localEvent.reminders = [Reminder(minutes: 10)];
+ localEvent.start = TZDateTime.from(
+ toDartDatetime(acterEvent.utcStart()),
+ UTC,
+ );
+ localEvent.end = TZDateTime.from(
+ toDartDatetime(acterEvent.utcEnd()),
+ UTC,
+ );
+ localEvent.status = switch (rsvp) {
+ 'yes' => EventStatus.Confirmed,
+ 'maybe' => EventStatus.Tentative,
+ _ => EventStatus.None
+ };
+ return localEvent;
+}
+
+Future> _findActerCalendars() async {
+ // confirm this key exists.
+ final calendars = _logError(
+ await deviceCalendar.retrieveCalendars(),
+ 'Failed to load calendars',
+ );
+ if (calendars == null) {
+ return [];
+ }
+ if (Platform.isAndroid) {
+ return calendars
+ .where(
+ (c) =>
+ c.accountType == 'LOCAL' &&
+ c.accountName == 'Acter' &&
+ c.name == 'Acter',
+ )
+ .map((c) {
+ _log.info('Scheduling to delete ${c.id} (${c.accountType})');
+ return c.id!;
+ }).toList();
+ }
+ return calendars
+ .where((c) => c.accountType == 'Local' && c.name == 'Acter')
+ .map((c) {
+ _log.info('Scheduling to delete ${c.id} (${c.accountType})');
+ return c.id!;
+ }).toList();
+}
+
+Future clearActerCalendars() async {
+ final calendars = await _findActerCalendars();
+ if (calendars.isNotEmpty) {
+ _log.info('Deleting acter named calendars', calendars);
+ await _deleteCalendars(calendars);
+ }
+}
+
+Future _deleteCalendars(List toDelete) async {
+ for (final calendarId in toDelete) {
+ _logError(
+ await deviceCalendar.deleteCalendar(calendarId),
+ 'Deleting of $calendarId failed',
+ );
+ }
+}
+
+Future _getOrCreateCalendar() async {
+ final preferences = await sharedPrefs();
+ final storedKey = preferences.getString(calendarSyncKey);
+ // confirm this key exists.
+ final calendars = _logError(
+ await deviceCalendar.retrieveCalendars(),
+ 'Failed to load calendars',
+ );
+ if (storedKey != null) {
+ _log.info('Previous key found $storedKey');
+ if (calendars != null) {
+ for (final calendar in calendars) {
+ if (calendar.id == storedKey) {
+ _log.info('Existing calendar found $storedKey');
+ return storedKey;
+ }
+ }
+ }
+ }
+
+ // find old and remove them
+ await clearActerCalendars();
+
+ _log.info('No previous calendar found, creating a new one');
+
+ // fallback: calendar not found or not yet created. Create one
+ final newCalendarId = _logError(
+ await deviceCalendar.createCalendar(
+ 'Acter',
+ calendarColor: brandColor,
+ localAccountName: 'Acter',
+ ),
+ 'Failed to create new calendar',
+ doThrow: true,
+ )!;
+ await preferences.setString(calendarSyncKey, newCalendarId);
+ return newCalendarId;
+}
diff --git a/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart b/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart
new file mode 100644
index 000000000000..6e901663e866
--- /dev/null
+++ b/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart
@@ -0,0 +1,32 @@
+//ALL UPCOMING EVENTS
+import 'package:acter/features/events/actions/get_event_type.dart';
+import 'package:acter/features/events/providers/event_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+typedef EventAndRsvp = ({CalendarEvent event, String? rsvp});
+
+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 = getEventType(event);
+ return eventType == EventFilters.upcoming ||
+ eventType == EventFilters.ongoing;
+ });
+ final List toSync = [];
+
+ for (final event in upcomingAndOngoing) {
+ final eventId = event.eventId().toString();
+ final myRsvpStatus = await ref.watch(myRsvpStatusProvider(eventId).future);
+ final rsvpStatus = myRsvpStatus.statusStr();
+ if (rsvpStatus != 'no') {
+ // we sync all that aren't denied yet
+ final event = await ref.watch(
+ calendarEventProvider(eventId).future,
+ ); // ensure we are listening to updates of the events themselves
+ toSync.add((event: event, rsvp: rsvpStatus));
+ }
+ }
+ return toSync;
+});
diff --git a/app/lib/features/home/widgets/sidebar_widget.dart b/app/lib/features/home/widgets/sidebar_widget.dart
index 8983753aa623..515c4d04f948 100644
--- a/app/lib/features/home/widgets/sidebar_widget.dart
+++ b/app/lib/features/home/widgets/sidebar_widget.dart
@@ -5,7 +5,7 @@ import 'package:acter/common/utils/constants.dart';
import 'package:acter/common/utils/routes.dart';
import 'package:acter/common/widgets/user_avatar.dart';
import 'package:acter/features/home/data/keys.dart';
-import 'package:acter/features/home/pages/home_shell.dart';
+import 'package:acter/config/app_shell.dart';
import 'package:acter/features/home/widgets/activities_icon.dart';
import 'package:acter/features/home/widgets/chats_icon.dart';
import 'package:acter/router/providers/router_providers.dart';
diff --git a/app/lib/features/search/widgets/quick_actions_builder.dart b/app/lib/features/search/widgets/quick_actions_builder.dart
index 2c826885dedb..3247e7f334ca 100644
--- a/app/lib/features/search/widgets/quick_actions_builder.dart
+++ b/app/lib/features/search/widgets/quick_actions_builder.dart
@@ -2,7 +2,7 @@ import 'package:acter/common/providers/space_providers.dart';
import 'package:acter/common/themes/colors/color_scheme.dart';
import 'package:acter/common/utils/routes.dart';
import 'package:acter/common/utils/utils.dart';
-import 'package:acter/features/home/pages/home_shell.dart';
+import 'package:acter/config/app_shell.dart';
import 'package:acter/features/search/model/keys.dart';
import 'package:acter/features/settings/providers/settings_providers.dart';
import 'package:acter/features/spaces/model/keys.dart';
diff --git a/app/lib/features/settings/pages/labs_page.dart b/app/lib/features/settings/pages/labs_page.dart
index 813bb908d8b2..f34c1d2cf022 100644
--- a/app/lib/features/settings/pages/labs_page.dart
+++ b/app/lib/features/settings/pages/labs_page.dart
@@ -1,5 +1,6 @@
import 'package:acter/common/utils/utils.dart';
import 'package:acter/common/widgets/with_sidebar.dart';
+import 'package:acter/features/calendar_sync/calendar_sync.dart';
import 'package:acter/features/settings/pages/settings_page.dart';
import 'package:acter/features/settings/providers/settings_providers.dart';
import 'package:acter/features/settings/widgets/labs_notifications_settings_tile.dart';
@@ -69,6 +70,34 @@ class SettingsLabsPage extends ConsumerWidget {
),
],
),
+ SettingsSection(
+ title: Text(L10n.of(context).calendar),
+ tiles: [
+ SettingsTile.switchTile(
+ enabled: isSupportedPlatform,
+ title: Text(L10n.of(context).calendarSyncFeatureTitle),
+ description: Text(
+ L10n.of(context).calendarSyncFeatureDesc,
+ ),
+ initialValue: isSupportedPlatform &&
+ ref.watch(
+ isActiveProvider(LabsFeature.deviceCalendarSync),
+ ),
+ onToggle: (newVal) async {
+ await updateFeatureState(
+ ref,
+ LabsFeature.deviceCalendarSync,
+ newVal,
+ );
+ if (newVal) {
+ initCalendarSync(ignoreRejection: true);
+ } else {
+ clearActerCalendars();
+ }
+ },
+ ),
+ ],
+ ),
SettingsSection(
title: Text(L10n.of(context).apps),
tiles: [
diff --git a/app/lib/features/settings/providers/notifiers/labs_features.dart b/app/lib/features/settings/providers/notifiers/labs_features.dart
index 1d4347620ab9..76520eb24c93 100644
--- a/app/lib/features/settings/providers/notifiers/labs_features.dart
+++ b/app/lib/features/settings/providers/notifiers/labs_features.dart
@@ -1,28 +1,58 @@
+import 'dart:async';
+
import 'package:acter/common/utils/feature_flagger.dart';
import 'package:acter/common/utils/utils.dart';
-import 'package:flutter/foundation.dart';
-import 'package:shared_preferences/shared_preferences.dart';
+import 'package:acter/features/settings/providers/settings_providers.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk.dart';
-import 'dart:convert';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
-class SharedPrefFeaturesNotifier extends FeaturesNotifier {
- late SharedPreferences prefInstance;
- late VoidCallback listener;
- late String instanceKey;
- SharedPrefFeaturesNotifier(this.instanceKey, initState) : super(initState) {
+class SharedPrefFeaturesNotifier extends StateNotifier> {
+ late ProviderSubscription>> listener;
+ final String instanceKey;
+ final Ref ref;
+ SharedPrefFeaturesNotifier(this.instanceKey, this.ref)
+ : super(
+ Features(
+ flags: const [],
+ defaultOn: LabsFeature.defaults,
+ ),
+ ) {
_init();
}
- void _init() async {
- prefInstance = await sharedPrefs();
- final currentData = prefInstance.getString(instanceKey) ?? '[]';
- final features = featureFlagsFromJson(
- json.decode(currentData),
- (name) => LabsFeature.values.byName(name),
- );
- resetFeatures(features);
- listener = addListener((s) {
- prefInstance.setString(instanceKey, s.toJson());
+ void _init() {
+ listener = ref.listen(asyncFeaturesProvider, (prev, newValue) {
+ if (!newValue.hasValue) {
+ // ignore and wait until it has loaded, debounce in-between
+ return;
+ }
+ state = newValue.value!;
+ });
+ }
+
+ Future newState(Features s) async {
+ final prefInstance = await sharedPrefs();
+ prefInstance.setString(instanceKey, s.toJson());
+ final completer = Completer();
+ final removeListener = addListener((_) {
+ completer.complete();
+ });
+ final future = completer.future;
+ future.whenComplete(() {
+ // ensure we only fire once
+ removeListener();
});
+ ref.invalidate(asyncFeaturesProvider);
+ return future;
+ }
+
+ // Let's the UI update the state of a flag
+ Future setActive(LabsFeature f, bool active) {
+ return newState(state.updateFlag(f, active));
+ }
+
+ // Allow higher level to reset the features flagged
+ Future resetFeatures(List> features) {
+ return newState(Features(flags: features, defaultOn: state.defaultOn));
}
}
diff --git a/app/lib/features/settings/providers/settings_providers.dart b/app/lib/features/settings/providers/settings_providers.dart
index 3e9ca2c9dce0..1d4918e83922 100644
--- a/app/lib/features/settings/providers/settings_providers.dart
+++ b/app/lib/features/settings/providers/settings_providers.dart
@@ -1,24 +1,40 @@
+import 'dart:convert';
+
import 'package:acter/common/providers/common_providers.dart';
import 'package:acter/common/utils/feature_flagger.dart';
import 'package:acter/common/utils/main.dart';
import 'package:acter/common/utils/utils.dart';
import 'package:acter/features/home/providers/client_providers.dart';
import 'package:acter/features/settings/providers/notifiers/labs_features.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
-import 'package:riverpod/riverpod.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
final allowSentryReportingProvider =
FutureProvider((ref) => getCanReportToSentry());
+const labsKey = 'a3.labs';
+
+final asyncFeaturesProvider =
+ FutureProvider>((ref) async {
+ final prefInstance = await sharedPrefs();
+ final currentData = prefInstance.getString(labsKey) ?? '[]';
+ final features = featureFlagsFromJson(
+ json.decode(currentData),
+ (name) => LabsFeature.values.byName(name),
+ );
+ return Features(
+ flags: features,
+ defaultOn: LabsFeature.defaults,
+ );
+});
+
final featuresProvider =
StateNotifierProvider>(
(ref) {
return SharedPrefFeaturesNotifier(
- 'a3.labs',
- Features(
- flags: const [],
- defaultOn: LabsFeature.defaults,
- ),
+ labsKey,
+ ref,
);
});
@@ -62,8 +78,14 @@ final isActiveProvider = StateProvider.family(
(ref, feature) => ref.watch(featuresProvider).isActive(feature),
);
+final asyncIsActiveProvider =
+ FutureProvider.family((ref, feature) async {
+ return (await ref.watch(asyncFeaturesProvider.future)).isActive(feature);
+});
+
// helper
-bool updateFeatureState(ref, f, value) {
- ref.read(featuresProvider.notifier).setActive(f, value);
- return value;
+Future updateFeatureState(
+ WidgetRef ref, LabsFeature f, bool value,) async {
+ await ref.read(featuresProvider.notifier).setActive(f, value);
+ return ref.read(featuresProvider).isActive(f);
}
diff --git a/app/lib/features/settings/widgets/labs_notifications_settings_tile.dart b/app/lib/features/settings/widgets/labs_notifications_settings_tile.dart
index 52022a1e5467..986bee387804 100644
--- a/app/lib/features/settings/widgets/labs_notifications_settings_tile.dart
+++ b/app/lib/features/settings/widgets/labs_notifications_settings_tile.dart
@@ -43,10 +43,11 @@ class _LabNotificationSettingsTile extends ConsumerWidget {
WidgetRef ref,
bool newVal,
) async {
- updateFeatureState(ref, LabsFeature.mobilePushNotifications, newVal);
+ final lang = L10n.of(context);
+ await updateFeatureState(ref, LabsFeature.mobilePushNotifications, newVal);
if (!newVal) return;
final client = ref.read(alwaysClientProvider);
- EasyLoading.show(status: L10n.of(context).changingSettings);
+ EasyLoading.show(status: lang.changingSettings);
try {
var granted = await setupPushNotifications(client, forced: true);
if (granted) {
@@ -61,13 +62,13 @@ class _LabNotificationSettingsTile extends ConsumerWidget {
}
// second attempt, even sending the user to the settings, they do not
// approve. Let's kick it back off
- updateFeatureState(ref, LabsFeature.mobilePushNotifications, false);
+ await updateFeatureState(ref, LabsFeature.mobilePushNotifications, false);
if (!context.mounted) {
EasyLoading.dismiss();
return;
}
EasyLoading.showToast(
- L10n.of(context).changedPushNotificationSettingsSuccessfully,
+ lang.changedPushNotificationSettingsSuccessfully,
);
} catch (e, st) {
_log.severe('Failed to change settings', e, st);
@@ -76,7 +77,7 @@ class _LabNotificationSettingsTile extends ConsumerWidget {
return;
}
EasyLoading.showError(
- L10n.of(context).failedToChangePushNotificationSettings(e),
+ lang.failedToChangePushNotificationSettings(e),
duration: const Duration(seconds: 3),
);
}
diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb
index dd4c3513b7fd..6d92d81716d4 100644
--- a/app/lib/l10n/app_en.arb
+++ b/app/lib/l10n/app_en.arb
@@ -121,6 +121,12 @@
"@builtOnShouldersOfGiants": {},
"calendarEventsFromAllTheSpaces": "Calendar events from all the Spaces you are part of",
"@calendarEventsFromAllTheSpaces": {},
+ "calendar": "Calendar",
+ "@calendar": {},
+ "calendarSyncFeatureTitle": "Calendar Sync",
+ "@calendarSyncFeatureTitle": {},
+ "calendarSyncFeatureDesc": "Sync (tentative and accepted) events with device calendar (Android & iOS only)",
+ "@calendarSyncFeatureDesc": {},
"camera": "Camera",
"@camera": {},
"cancel": "Cancel",
diff --git a/app/lib/router/router.dart b/app/lib/router/router.dart
index 7b317513c7ff..72773c4e0d53 100644
--- a/app/lib/router/router.dart
+++ b/app/lib/router/router.dart
@@ -1,7 +1,7 @@
import 'package:acter/common/pages/not_found.dart';
import 'package:acter/common/utils/constants.dart';
import 'package:acter/common/utils/routes.dart';
-import 'package:acter/features/home/pages/home_shell.dart';
+import 'package:acter/config/app_shell.dart';
import 'package:acter/features/home/providers/client_providers.dart';
import 'package:acter/router/general_router.dart';
import 'package:acter/router/shell_routers/activities_shell_router.dart';
@@ -104,7 +104,7 @@ final GlobalKey rootNavKey = GlobalKey(
debugLabel: 'root',
);
-final homeShellKey = GlobalKey(debugLabel: 'home-shell');
+final appShellKey = GlobalKey(debugLabel: 'home-shell');
final GlobalKey homeTabNavKey = GlobalKey(
debugLabel: 'homeTabNavKey',
@@ -158,7 +158,7 @@ final goRouter = GoRouter(
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
- return HomeShell(key: homeShellKey, navigationShell: navigationShell);
+ return AppShell(key: appShellKey, navigationShell: navigationShell);
},
branches: shellBranches,
),
diff --git a/app/lib/router/utils.dart b/app/lib/router/utils.dart
index af4e8387491f..e95e503ff3e7 100644
--- a/app/lib/router/utils.dart
+++ b/app/lib/router/utils.dart
@@ -1,7 +1,7 @@
import 'package:acter/common/providers/chat_providers.dart';
import 'package:acter/common/utils/routes.dart';
import 'package:acter/common/utils/utils.dart';
-import 'package:acter/features/home/pages/home_shell.dart';
+import 'package:acter/config/app_shell.dart';
import 'package:acter/router/providers/router_providers.dart';
import 'package:acter/router/router.dart';
import 'package:flutter/material.dart';
@@ -33,7 +33,7 @@ bool navigateOnRightBranch(
bool initialLocation = true,
}) {
final navState =
- (homeShellKey.currentContext?.widget as HomeShell).navigationShell;
+ (appShellKey.currentContext?.widget as AppShell).navigationShell;
if (navState.currentIndex != targetBranch.index) {
// when routed to chat, we always want to jump to the chat
// tab
diff --git a/app/pubspec.lock b/app/pubspec.lock
index cc95ff69347a..7ae84c26b508 100644
--- a/app/pubspec.lock
+++ b/app/pubspec.lock
@@ -431,6 +431,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.10"
+ device_calendar:
+ dependency: "direct main"
+ description:
+ name: device_calendar
+ sha256: "991b55bb9e0a0850ec9367af8227fe25185210da4f5fa7bd15db4cc813b1e2e5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.3.2"
device_info_plus:
dependency: "direct main"
description:
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index f96b4be3d326..d3b0c5d7963c 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -141,6 +141,7 @@ dependencies:
path: ../packages/shake_detector
open_filex: ^4.5.0
phosphor_flutter: ^2.1.0
+ device_calendar: ^4.3.2
dev_dependencies:
flutter_test: