diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 0a0b3286e7f..a8393dbf62d 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1230,6 +1230,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronize: + dependency: "direct overridden" + description: + path: "../synchronize" + relative: true + source: path + version: "1.0.0" synchronized: dependency: transitive description: diff --git a/packages/app/pubspec_overrides.yaml b/packages/app/pubspec_overrides.yaml index 43095c1170f..20f5dba6750 100644 --- a/packages/app/pubspec_overrides.yaml +++ b/packages/app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon_dashboard,neon_files,neon_framework,neon_lints,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../dynamite/dynamite_runtime @@ -22,3 +22,5 @@ dependency_overrides: path: ../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon/neon_dashboard/pubspec_overrides.yaml b/packages/neon/neon_dashboard/pubspec_overrides.yaml index 76e02e96ad7..5c0955ff1fc 100644 --- a/packages/neon/neon_dashboard/pubspec_overrides.yaml +++ b/packages/neon/neon_dashboard/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_files/pubspec_overrides.yaml b/packages/neon/neon_files/pubspec_overrides.yaml index 7d69f8a4139..4baefa73c70 100644 --- a/packages/neon/neon_files/pubspec_overrides.yaml +++ b/packages/neon/neon_files/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon_framework,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon_framework,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -12,3 +12,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_news/pubspec_overrides.yaml b/packages/neon/neon_news/pubspec_overrides.yaml index 76e02e96ad7..5c0955ff1fc 100644 --- a/packages/neon/neon_news/pubspec_overrides.yaml +++ b/packages/neon/neon_news/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_notes/pubspec_overrides.yaml b/packages/neon/neon_notes/pubspec_overrides.yaml index 76e02e96ad7..5c0955ff1fc 100644 --- a/packages/neon/neon_notes/pubspec_overrides.yaml +++ b/packages/neon/neon_notes/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_notifications/pubspec_overrides.yaml b/packages/neon/neon_notifications/pubspec_overrides.yaml index 76e02e96ad7..5c0955ff1fc 100644 --- a/packages/neon/neon_notifications/pubspec_overrides.yaml +++ b/packages/neon/neon_notifications/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon_framework,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index a7db74f0ea4..0877cbb3b0a 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -87,6 +87,9 @@ "actionContinue": "Continue", "actionCancel": "Cancel", "actionDone": "Done", + "actionPrevious": "Previous", + "actionNext": "Next", + "actionFinish": "Finish", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "nextPushSupported": "NextPush is supported!", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", @@ -248,5 +251,42 @@ "userStatusClearAtThisWeek": "This week", "userStatusActionClear": "Clear status", "userStatusStatusMessage": "Status message", - "userStatusOnlineStatus": "Online status" + "userStatusOnlineStatus": "Online status", + "sync": "Synchronization", + "syncOptionsNoSynchronizations": "No {type} synchronizations", + "@syncOptionsNoSynchronizations": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "syncOptionsAdd": "Add {type} synchronization", + "@syncOptionsAdd": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "syncOptionsRemove": "Remove synchronization", + "syncOptionsSyncNow": "Synchronize now", + "syncOptionsStatusUnknown": "Unknown synchronization status", + "syncOptionsStatusIncomplete": "Not completely synchronized", + "syncOptionsStatusComplete": "Completely synchronized", + "syncOptionsRemoveConfirmation": "Do you want to remove the synchronization?", + "syncOptionsAutomaticSync": "Sync automatically", + "syncResolveConflictsLocal": "Local", + "syncResolveConflictsRemote": "Remote", + "syncResolveConflictsTitle": "Found {count} conflicts for syncing {name}", + "@syncResolveConflictsTitle": { + "placeholders": { + "count": { + "type": "int" + }, + "name": { + "type": "String" + } + } + } } diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index d2fd21809c9..131de0de051 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -329,6 +329,24 @@ abstract class NeonLocalizations { /// **'Done'** String get actionDone; + /// No description provided for @actionPrevious. + /// + /// In en, this message translates to: + /// **'Previous'** + String get actionPrevious; + + /// No description provided for @actionNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get actionNext; + + /// No description provided for @actionFinish. + /// + /// In en, this message translates to: + /// **'Finish'** + String get actionFinish; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: @@ -844,6 +862,84 @@ abstract class NeonLocalizations { /// In en, this message translates to: /// **'Online status'** String get userStatusOnlineStatus; + + /// No description provided for @sync. + /// + /// In en, this message translates to: + /// **'Synchronization'** + String get sync; + + /// No description provided for @syncOptionsNoSynchronizations. + /// + /// In en, this message translates to: + /// **'No {type} synchronizations'** + String syncOptionsNoSynchronizations(String type); + + /// No description provided for @syncOptionsAdd. + /// + /// In en, this message translates to: + /// **'Add {type} synchronization'** + String syncOptionsAdd(String type); + + /// No description provided for @syncOptionsRemove. + /// + /// In en, this message translates to: + /// **'Remove synchronization'** + String get syncOptionsRemove; + + /// No description provided for @syncOptionsSyncNow. + /// + /// In en, this message translates to: + /// **'Synchronize now'** + String get syncOptionsSyncNow; + + /// No description provided for @syncOptionsStatusUnknown. + /// + /// In en, this message translates to: + /// **'Unknown synchronization status'** + String get syncOptionsStatusUnknown; + + /// No description provided for @syncOptionsStatusIncomplete. + /// + /// In en, this message translates to: + /// **'Not completely synchronized'** + String get syncOptionsStatusIncomplete; + + /// No description provided for @syncOptionsStatusComplete. + /// + /// In en, this message translates to: + /// **'Completely synchronized'** + String get syncOptionsStatusComplete; + + /// No description provided for @syncOptionsRemoveConfirmation. + /// + /// In en, this message translates to: + /// **'Do you want to remove the synchronization?'** + String get syncOptionsRemoveConfirmation; + + /// No description provided for @syncOptionsAutomaticSync. + /// + /// In en, this message translates to: + /// **'Sync automatically'** + String get syncOptionsAutomaticSync; + + /// No description provided for @syncResolveConflictsLocal. + /// + /// In en, this message translates to: + /// **'Local'** + String get syncResolveConflictsLocal; + + /// No description provided for @syncResolveConflictsRemote. + /// + /// In en, this message translates to: + /// **'Remote'** + String get syncResolveConflictsRemote; + + /// No description provided for @syncResolveConflictsTitle. + /// + /// In en, this message translates to: + /// **'Found {count} conflicts for syncing {name}'** + String syncResolveConflictsTitle(int count, String name); } class _NeonLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index 68ccb089549..483210754be 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -156,6 +156,15 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get actionDone => 'Done'; + @override + String get actionPrevious => 'Previous'; + + @override + String get actionNext => 'Next'; + + @override + String get actionFinish => 'Finish'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; @@ -460,4 +469,49 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get userStatusOnlineStatus => 'Online status'; + + @override + String get sync => 'Synchronization'; + + @override + String syncOptionsNoSynchronizations(String type) { + return 'No $type synchronizations'; + } + + @override + String syncOptionsAdd(String type) { + return 'Add $type synchronization'; + } + + @override + String get syncOptionsRemove => 'Remove synchronization'; + + @override + String get syncOptionsSyncNow => 'Synchronize now'; + + @override + String get syncOptionsStatusUnknown => 'Unknown synchronization status'; + + @override + String get syncOptionsStatusIncomplete => 'Not completely synchronized'; + + @override + String get syncOptionsStatusComplete => 'Completely synchronized'; + + @override + String get syncOptionsRemoveConfirmation => 'Do you want to remove the synchronization?'; + + @override + String get syncOptionsAutomaticSync => 'Sync automatically'; + + @override + String get syncResolveConflictsLocal => 'Local'; + + @override + String get syncResolveConflictsRemote => 'Remote'; + + @override + String syncResolveConflictsTitle(int count, String name) { + return 'Found $count conflicts for syncing $name'; + } } diff --git a/packages/neon_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index 381e0f2a75d..2eeb3328dad 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -8,6 +8,7 @@ import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/blocs/first_launch.dart'; import 'package:neon_framework/src/blocs/next_push.dart'; import 'package:neon_framework/src/blocs/push_notifications.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; import 'package:neon_framework/src/models/disposable.dart'; @@ -65,6 +66,10 @@ Future runNeon({ globalOptions, disabled: nextPushDisabled, ); + final syncBloc = SyncBloc( + accountsBloc, + appImplementations, + ); runApp( MultiProvider( @@ -73,6 +78,7 @@ Future runNeon({ NeonProvider.value(value: accountsBloc), NeonProvider.value(value: firstLaunchBloc), NeonProvider.value(value: nextPushBloc), + NeonProvider.value(value: syncBloc), Provider>( create: (_) => appImplementations, dispose: (_, appImplementations) => appImplementations.disposeAll(), diff --git a/packages/neon_framework/lib/src/blocs/apps.dart b/packages/neon_framework/lib/src/blocs/apps.dart index 97df6f8425a..a1326e15002 100644 --- a/packages/neon_framework/lib/src/blocs/apps.dart +++ b/packages/neon_framework/lib/src/blocs/apps.dart @@ -63,6 +63,12 @@ abstract class AppsBloc implements InteractiveBloc { /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. T getAppBloc(AppImplementation appImplementation); + /// Returns the active [Bloc] for the given [appId]. + /// + /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. + /// See [getAppBloc] for getting the [Bloc] by the [AppImplementation]. + T getAppBlocByID(String appId); + /// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider. List> get appBlocProviders; } @@ -284,6 +290,9 @@ class _AppsBloc extends InteractiveBloc implements AppsBloc { @override T getAppBloc(AppImplementation appImplementation) => appImplementation.getBloc(account); + @override + T getAppBlocByID(String appId) => allAppImplementations.find(appId).getBloc(account) as T; + @override List> get appBlocProviders => allAppImplementations.map((appImplementation) => appImplementation.blocProvider).toList(); diff --git a/packages/neon_framework/lib/src/blocs/sync.dart b/packages/neon_framework/lib/src/blocs/sync.dart new file mode 100644 index 00000000000..883740c1d74 --- /dev/null +++ b/packages/neon_framework/lib/src/blocs/sync.dart @@ -0,0 +1,285 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/src/models/account.dart'; +import 'package:neon_framework/src/models/app_implementation.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/storage/storage_manager.dart'; +import 'package:neon_framework/src/sync/models/conflicts.dart'; +import 'package:neon_framework/src/sync/models/implementation.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/utils/findable.dart'; +import 'package:neon_framework/src/utils/sync_mapping_options.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:synchronize/synchronize.dart'; + +sealed class SyncBloc implements InteractiveBloc { + factory SyncBloc( + AccountsBloc accountsBloc, + Iterable appImplementations, + ) => + _SyncBloc( + accountsBloc, + appImplementations, + ); + + /// Adds a new [mapping] that will be synced. + Future addMapping(SyncMapping mapping); + + /// Removes an existing [mapping] that will no longer be synced. + Future removeMapping(SyncMapping mapping); + + /// Explicitly trigger a sync for the [mapping]. + /// [solutions] can be use to apply solutions for conflicts. + Future syncMapping( + SyncMapping mapping, { + Map solutions = const {}, + }); + + /// Map of [SyncMapping]s and their [SyncMappingStatus]es + BehaviorSubject, SyncMappingStatus>> get mappingStatuses; + + /// Stream of conflicts that have arisen during syncing. + Stream> get conflicts; + + SyncMappingOptions getSyncMappingOptionsFor(SyncMapping mapping); +} + +class _SyncBloc extends InteractiveBloc implements SyncBloc { + _SyncBloc( + this.accountsBloc, + Iterable appImplementations, + ) { + _syncImplementations = appImplementations.map((app) => app.syncImplementation).whereNotNull(); + _timer = TimerBloc().registerTimer(const Duration(minutes: 1), refresh); + + _loadMappings(); + mappingStatuses.value.keys.forEach(_watchMapping); + unawaited(refresh()); + } + + final AccountsBloc accountsBloc; + static final _storage = NeonStorage().singleValueStore(StorageKeys.sync); + late final Iterable, dynamic, dynamic>> _syncImplementations; + late final NeonTimer _timer; + final _conflictsController = StreamController>(); + final _watchControllers = >{}; + final _syncMappingOptions = {}; + + @override + void dispose() { + _timer.cancel(); + for (final options in _syncMappingOptions.values) { + options.dispose(); + } + for (final mapping in mappingStatuses.value.keys) { + mapping.dispose(); + } + unawaited(mappingStatuses.close()); + for (final controller in _watchControllers.values) { + unawaited(controller.close()); + } + unawaited(_conflictsController.close()); + + super.dispose(); + } + + @override + late final Stream> conflicts = _conflictsController.stream.asBroadcastStream(); + + @override + final BehaviorSubject, SyncMappingStatus>> mappingStatuses = BehaviorSubject(); + + @override + Future refresh() async { + for (final mapping in mappingStatuses.value.keys) { + await _updateMapping(mapping); + } + } + + @override + Future addMapping(SyncMapping mapping) async { + debugPrint('Adding mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.unknown, + }); + await _saveMappings(); + // Directly trigger sync after adding the mapping + await syncMapping(mapping); + // And start watching for local or remote changes + _watchMapping(mapping); + } + + @override + Future removeMapping(SyncMapping mapping) async { + debugPrint('Removing mapping: $mapping'); + mappingStatuses.add(Map.fromEntries(mappingStatuses.value.entries.where((m) => m.key != mapping))); + mapping.dispose(); + await _saveMappings(); + } + + @override + Future syncMapping( + SyncMapping mapping, { + Map solutions = const {}, + }) async { + debugPrint('Syncing mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.incomplete, + }); + + final account = accountsBloc.accounts.value.tryFind(mapping.accountId); + if (account == null) { + await removeMapping(mapping); + return; + } + + try { + final implementation = _syncImplementations.find(mapping.appId); + final sources = await implementation.getSources(account, mapping); + + final diff = await computeSyncDiff( + sources, + mapping.journal, + conflictSolutions: solutions, + keepSkipsAsConflicts: true, + ); + debugPrint('Journal: ${mapping.journal}'); + debugPrint('Conflicts: ${diff.conflicts}'); + debugPrint('Actions: ${diff.actions}'); + + if (diff.conflicts.isNotEmpty && diff.conflicts.whereNot((conflict) => conflict.skipped).isNotEmpty) { + _conflictsController.add( + SyncConflicts( + account, + implementation, + mapping, + diff.conflicts, + ), + ); + } + + await executeSyncDiff(sources, mapping.journal, diff); + + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete, + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + addError(e); + } + + // Save after syncing even if an error occurred + await _saveMappings(); + } + + Future _updateMapping(SyncMapping mapping) async { + final account = accountsBloc.accounts.value.tryFind(mapping.accountId); + if (account == null) { + await removeMapping(mapping); + return; + } + + final options = getSyncMappingOptionsFor(mapping); + if (options.automaticSync.value) { + await syncMapping(mapping); + } else { + try { + final status = await _getMappingStatus(account, mapping); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: status, + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + addError(e); + } + } + } + + Future _getMappingStatus( + Account account, + SyncMapping mapping, + ) async { + final implementation = _syncImplementations.find(mapping.appId); + final sources = await implementation.getSources(account, mapping); + final diff = await computeSyncDiff(sources, mapping.journal); + return diff.actions.isEmpty && diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete; + } + + void _loadMappings() { + debugPrint('Loading mappings'); + final loadedMappings = >[]; + + final value = _storage.getString(); + if (value != null && value.isNotEmpty) { + final serializedMappings = (json.decode(value) as Map) + .map((key, value) => MapEntry(key, (value as List).map((e) => e as Map))); + + for (final mapping in serializedMappings.entries) { + final syncImplementation = _syncImplementations.tryFind(mapping.key); + if (syncImplementation == null) { + continue; + } + + for (final serializedMapping in mapping.value) { + loadedMappings.add(syncImplementation.deserializeMapping(serializedMapping)); + } + } + } + + mappingStatuses.add({ + for (final mapping in loadedMappings) mapping: SyncMappingStatus.unknown, + }); + } + + Future _saveMappings() async { + debugPrint('Saving mappings'); + final serializedMappings = >>{}; + + for (final mapping in mappingStatuses.value.keys) { + final syncImplementation = _syncImplementations.find(mapping.appId); + serializedMappings[mapping.appId] ??= []; + serializedMappings[mapping.appId]!.add(syncImplementation.serializeMapping(mapping)); + } + + await _storage.setString(json.encode(serializedMappings)); + } + + void _watchMapping(SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + if (_watchControllers.containsKey(syncImplementation.getMappingId(mapping))) { + return; + } + + // ignore: close_sinks + final controller = StreamController(); + // Debounce is required to stop bulk operations flooding the sync and potentially creating race conditions. + controller.stream.debounceTime(const Duration(seconds: 1)).listen((_) async { + await _updateMapping(mapping); + }); + + _watchControllers[syncImplementation.getMappingId(mapping)] = controller; + + mapping.watch(() { + controller.add(null); + }); + } + + @override + SyncMappingOptions getSyncMappingOptionsFor(SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + final id = syncImplementation.getGlobalUniqueMappingId(mapping); + return _syncMappingOptions[id] ??= SyncMappingOptions( + NeonStorage().settingsStore(StorageKeys.sync, id), + ); + } +} diff --git a/packages/neon_framework/lib/src/models/app_implementation.dart b/packages/neon_framework/lib/src/models/app_implementation.dart index 1b88abc5434..1bf40255c4b 100644 --- a/packages/neon_framework/lib/src/models/app_implementation.dart +++ b/packages/neon_framework/lib/src/models/app_implementation.dart @@ -11,7 +11,8 @@ import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/settings/models/options_collection.dart'; import 'package:neon_framework/src/storage/keys.dart'; - +import 'package:neon_framework/src/sync/models/implementation.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/drawer_destination.dart'; @@ -85,6 +86,9 @@ abstract class AppImplementation, dynamic, dynamic>? get syncImplementation => null; + /// The [Provider] building the bloc [T] the currently active account. /// /// Blocs will not be disposed on disposal of the provider. You must handle diff --git a/packages/neon_framework/lib/src/pages/sync.dart b/packages/neon_framework/lib/src/pages/sync.dart new file mode 100644 index 00000000000..da71bf8fdcd --- /dev/null +++ b/packages/neon_framework/lib/src/pages/sync.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; +import 'package:neon_framework/src/pages/sync_mapping_settings.dart'; +import 'package:neon_framework/src/settings/widgets/custom_settings_tile.dart'; +import 'package:neon_framework/src/settings/widgets/settings_category.dart'; +import 'package:neon_framework/src/theme/dialog.dart'; +import 'package:neon_framework/src/utils/findable.dart'; +import 'package:neon_framework/src/utils/provider.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/src/widgets/sync_status_icon.dart'; +import 'package:neon_framework/src/widgets/user_avatar.dart'; + +class SyncPage extends StatelessWidget { + const SyncPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final accountsBloc = NeonProvider.of(context); + final syncBloc = NeonProvider.of(context); + final appImplementations = + NeonProvider.of>(context).where((app) => app.syncImplementation != null); + + final body = StreamBuilder( + stream: syncBloc.mappingStatuses, + builder: (context, mappingStatuses) => !mappingStatuses.hasData + ? const SizedBox.shrink() + : ListView( + children: appImplementations.map( + (appImplementation) { + final appName = NeonLocalizations.of(context).appImplementationName(appImplementation.id); + final appMappingStatuses = mappingStatuses.requireData.entries + .where((mappingStatus) => mappingStatus.key.appId == appImplementation.id); + + return SettingsCategory( + title: Row( + children: [ + appImplementation.buildIcon(), + const SizedBox( + width: 5, + ), + Text(appName), + ], + ), + tiles: [ + if (appMappingStatuses.isEmpty) + CustomSettingsTile( + title: Text(NeonLocalizations.of(context).syncOptionsNoSynchronizations(appName)), + ), + for (final mappingStatus in appMappingStatuses) ...[ + CustomSettingsTile( + title: Text(appImplementation.syncImplementation!.getMappingDisplayTitle(mappingStatus.key)), + subtitle: + Text(appImplementation.syncImplementation!.getMappingDisplaySubtitle(mappingStatus.key)), + leading: NeonUserAvatar( + account: accountsBloc.accounts.value.find(mappingStatus.key.accountId), + showStatus: false, + ), + trailing: IconButton( + onPressed: () async { + await syncBloc.syncMapping(mappingStatus.key); + }, + tooltip: NeonLocalizations.of(context).syncOptionsSyncNow, + iconSize: 30, + icon: SyncStatusIcon( + status: mappingStatus.value, + ), + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SyncMappingSettingsPage( + mapping: mappingStatus.key, + ), + ), + ); + }, + ), + ], + CustomSettingsTile( + title: ElevatedButton.icon( + onPressed: () async { + final account = await showDialog( + context: context, + builder: (context) => const NeonAccountSelectionDialog(), + ); + if (account == null) { + return; + } + + if (!context.mounted) { + return; + } + + final mapping = await appImplementation.syncImplementation!.addMapping(context, account); + if (mapping == null) { + return; + } + + await syncBloc.addMapping(mapping); + }, + icon: const Icon(MdiIcons.cloudSync), + label: Text(NeonLocalizations.of(context).syncOptionsAdd(appName)), + ), + ), + ], + ); + }, + ).toList(), + ), + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(NeonLocalizations.of(context).sync), + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ), + ); + } +} diff --git a/packages/neon_framework/lib/src/pages/sync_mapping_settings.dart b/packages/neon_framework/lib/src/pages/sync_mapping_settings.dart new file mode 100644 index 00000000000..18bea2381ad --- /dev/null +++ b/packages/neon_framework/lib/src/pages/sync_mapping_settings.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; +import 'package:neon_framework/src/settings/widgets/option_settings_tile.dart'; +import 'package:neon_framework/src/settings/widgets/settings_category.dart'; +import 'package:neon_framework/src/settings/widgets/settings_list.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/utils/dialog.dart'; +import 'package:provider/provider.dart'; + +class SyncMappingSettingsPage extends StatelessWidget { + const SyncMappingSettingsPage({ + required this.mapping, + super.key, + }); + + final SyncMapping mapping; + + @override + Widget build(BuildContext context) { + final syncBloc = Provider.of(context, listen: false); + final options = syncBloc.getSyncMappingOptionsFor(mapping); + + return Scaffold( + appBar: AppBar( + title: Text(NeonLocalizations.of(context).sync), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context: context, + title: NeonLocalizations.of(context).syncOptionsRemoveConfirmation, + )) { + await syncBloc.removeMapping(mapping); + + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + tooltip: NeonLocalizations.of(context).syncOptionsRemove, + icon: const Icon(MdiIcons.delete), + ), + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context: context, + title: NeonLocalizations.of(context).settingsResetAllConfirmation, + )) { + options.reset(); + } + }, + tooltip: NeonLocalizations.of(context).settingsResetAll, + icon: const Icon(MdiIcons.cogRefresh), + ), + ], + ), + body: SettingsList( + categories: [ + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + ToggleSettingsTile( + option: options.automaticSync, + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/neon_framework/lib/src/router.dart b/packages/neon_framework/lib/src/router.dart index 813fb599e8b..54c3d79ba32 100644 --- a/packages/neon_framework/lib/src/router.dart +++ b/packages/neon_framework/lib/src/router.dart @@ -20,6 +20,7 @@ import 'package:neon_framework/src/pages/login_flow.dart'; import 'package:neon_framework/src/pages/login_qr_code.dart'; import 'package:neon_framework/src/pages/route_not_found.dart'; import 'package:neon_framework/src/pages/settings.dart'; +import 'package:neon_framework/src/pages/sync.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/provider.dart'; @@ -136,6 +137,9 @@ class AccountSettingsRoute extends GoRouteData { ), ], ), + TypedGoRoute( + path: 'sync', + ), ], ) @immutable @@ -488,3 +492,15 @@ class SettingsRoute extends GoRouteData { @override Widget build(BuildContext context, GoRouterState state) => SettingsPage(initialCategory: initialCategory); } + +/// {@template AppRoutes.SyncRoute} +/// Route for the the [SyncPage]. +/// {@endtemplate} +@immutable +class SyncRoute extends GoRouteData { + /// {@macro AppRoutes.SyncRoute} + const SyncRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const SyncPage(); +} diff --git a/packages/neon_framework/lib/src/router.g.dart b/packages/neon_framework/lib/src/router.g.dart index d91e5b01a30..ff0f41b049e 100644 --- a/packages/neon_framework/lib/src/router.g.dart +++ b/packages/neon_framework/lib/src/router.g.dart @@ -56,6 +56,10 @@ RouteBase get $homeRoute => GoRouteData.$route( ), ], ), + GoRouteData.$route( + path: 'sync', + factory: $SyncRouteExtension._fromState, + ), ], ); @@ -242,6 +246,22 @@ extension $AccountSettingsRouteExtension on AccountSettingsRoute { void replace(BuildContext context) => context.replace(location); } +extension $SyncRouteExtension on SyncRoute { + static SyncRoute _fromState(GoRouterState state) => const SyncRoute(); + + String get location => GoRouteData.$location( + '/sync', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + T? _$convertMapValue( String key, Map map, diff --git a/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart b/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart index 045bfdc12ba..47b3fb3fc4d 100644 --- a/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart +++ b/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart @@ -13,6 +13,7 @@ class CustomSettingsTile extends SettingsTile { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }); @@ -21,6 +22,7 @@ class CustomSettingsTile extends SettingsTile { final Widget? leading; final Widget? trailing; final FutureOr Function()? onTap; + final FutureOr Function()? onLongPress; @override Widget build(BuildContext context) => AdaptiveListTile( @@ -29,5 +31,6 @@ class CustomSettingsTile extends SettingsTile { leading: leading, trailing: trailing, onTap: onTap, + onLongPress: onLongPress, ); } diff --git a/packages/neon_framework/lib/src/storage/keys.dart b/packages/neon_framework/lib/src/storage/keys.dart index 9caedfa420f..fabf458e8e8 100644 --- a/packages/neon_framework/lib/src/storage/keys.dart +++ b/packages/neon_framework/lib/src/storage/keys.dart @@ -23,6 +23,9 @@ enum StorageKeys implements Storable { /// The key for the list of logged in `Account`s. accounts._('accounts-accounts'), + /// The key for the `SyncImplementation`s. + sync._('sync'), + /// The key for the `GlobalOptions`. global._('global'), diff --git a/packages/neon_framework/lib/src/sync/models/conflicts.dart b/packages/neon_framework/lib/src/sync/models/conflicts.dart new file mode 100644 index 00000000000..4eaeb12d9ca --- /dev/null +++ b/packages/neon_framework/lib/src/sync/models/conflicts.dart @@ -0,0 +1,18 @@ +import 'package:neon_framework/src/models/account.dart'; +import 'package:neon_framework/src/sync/models/implementation.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:synchronize/synchronize.dart'; + +class SyncConflicts { + SyncConflicts( + this.account, + this.implementation, + this.mapping, + this.conflicts, + ); + + final Account account; + final SyncImplementation, T1, T2> implementation; + final SyncMapping mapping; + final List> conflicts; +} diff --git a/packages/neon_framework/lib/src/sync/models/implementation.dart b/packages/neon_framework/lib/src/sync/models/implementation.dart new file mode 100644 index 00000000000..a436b59d1b4 --- /dev/null +++ b/packages/neon_framework/lib/src/sync/models/implementation.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:neon_framework/src/models/account.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/utils/findable.dart'; +import 'package:synchronize/synchronize.dart'; + +@immutable +abstract interface class SyncImplementation, T1, T2> implements Findable { + @override + String get id; + + FutureOr> getSources(Account account, S mapping); + + Map serializeMapping(S mapping); + + S deserializeMapping(Map json); + + FutureOr addMapping(BuildContext context, Account account); + + String getMappingDisplayTitle(S mapping); + + String getMappingDisplaySubtitle(S mapping); + + String getMappingId(S mapping); + + Widget getConflictDetailsLocal(BuildContext context, T2 object); + + Widget getConflictDetailsRemote(BuildContext context, T1 object); +} + +extension SyncImplementationGlobalUniqueMappingId + on SyncImplementation, dynamic, dynamic> { + String getGlobalUniqueMappingId(SyncMapping mapping) => + '${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}'; +} diff --git a/packages/neon_framework/lib/src/sync/models/mapping.dart b/packages/neon_framework/lib/src/sync/models/mapping.dart new file mode 100644 index 00000000000..f95ef7abdb8 --- /dev/null +++ b/packages/neon_framework/lib/src/sync/models/mapping.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; +import 'package:synchronize/synchronize.dart'; + +abstract interface class SyncMapping { + String get accountId; + String get appId; + SyncJournal get journal; + + /// This method can be implemented to watch local or remote changes and update the status accordingly. + void watch(void Function() onUpdated) {} + + @mustBeOverridden + void dispose() {} + + @override + String toString() => 'SyncMapping(accountId: $accountId, appId: $appId)'; +} + +enum SyncMappingStatus { + unknown, + incomplete, + complete, +} diff --git a/packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart b/packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart new file mode 100644 index 00000000000..65cdabe2b0e --- /dev/null +++ b/packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/sync/models/conflicts.dart'; +import 'package:neon_framework/src/sync/widgets/sync_conflict_card.dart'; +import 'package:neon_framework/src/theme/dialog.dart'; +import 'package:synchronize/synchronize.dart'; + +class NeonResolveSyncConflictsDialog extends StatefulWidget { + const NeonResolveSyncConflictsDialog({ + required this.conflicts, + super.key, + }); + + final SyncConflicts conflicts; + + @override + State> createState() => _NeonResolveSyncConflictsDialogState(); +} + +class _NeonResolveSyncConflictsDialogState extends State> { + var _index = 0; + final _solutions = {}; + + SyncConflict get conflict => widget.conflicts.conflicts[_index]; + + SyncConflictSolution? get selectedSolution => _solutions[conflict.id]; + + void onSolution(SyncConflictSolution solution) { + setState(() { + _solutions[conflict.id] = solution; + }); + } + + bool get isFirst => _index == 0; + bool get isLast => _index == widget.conflicts.conflicts.length - 1; + + @override + Widget build(BuildContext context) { + final body = Column( + children: [ + Text( + NeonLocalizations.of(context).syncResolveConflictsTitle( + widget.conflicts.conflicts.length, + NeonLocalizations.of(context).appImplementationName(widget.conflicts.implementation.id), + ), + style: Theme.of(context).textTheme.headlineMedium, + ), + const Divider(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SyncConflictCard( + title: NeonLocalizations.of(context).syncResolveConflictsLocal, + solution: SyncConflictSolution.overwriteA, + selected: selectedSolution == SyncConflictSolution.overwriteA, + onSelected: onSolution, + child: widget.conflicts.implementation.getConflictDetailsLocal(context, conflict.objectB.data), + ), + SyncConflictCard( + title: NeonLocalizations.of(context).syncResolveConflictsRemote, + solution: SyncConflictSolution.overwriteB, + selected: selectedSolution == SyncConflictSolution.overwriteB, + onSelected: onSolution, + child: widget.conflicts.implementation.getConflictDetailsRemote(context, conflict.objectA.data), + ), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OutlinedButton( + onPressed: () { + if (isFirst) { + Navigator.of(context).pop(); + } else { + setState(() { + _index--; + }); + } + }, + child: Text( + isFirst ? NeonLocalizations.of(context).actionCancel : NeonLocalizations.of(context).actionPrevious, + ), + ), + ElevatedButton( + onPressed: () { + if (isLast) { + Navigator.of(context).pop(_solutions); + } else { + setState(() { + _index++; + }); + } + }, + child: Text( + isLast ? NeonLocalizations.of(context).actionFinish : NeonLocalizations.of(context).actionNext, + ), + ), + ], + ), + ], + ); + + return Dialog( + child: IntrinsicHeight( + child: Container( + padding: const EdgeInsets.all(24), + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ); + } +} diff --git a/packages/neon_framework/lib/src/sync/widgets/sync_conflict_card.dart b/packages/neon_framework/lib/src/sync/widgets/sync_conflict_card.dart new file mode 100644 index 00000000000..c84c4d9860f --- /dev/null +++ b/packages/neon_framework/lib/src/sync/widgets/sync_conflict_card.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:synchronize/synchronize.dart'; + +class SyncConflictCard extends StatelessWidget { + const SyncConflictCard({ + required this.title, + required this.child, + required this.selected, + required this.solution, + required this.onSelected, + super.key, + }); + + final String title; + final Widget child; + final bool selected; + final SyncConflictSolution solution; + final void Function(SyncConflictSolution solution) onSelected; + + @override + Widget build(BuildContext context) => Card( + shape: selected + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.onBackground, + ), + ) + : null, + child: InkWell( + onTap: () { + onSelected(solution); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + title, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + child, + ], + ), + ), + ); +} diff --git a/packages/neon_framework/lib/src/utils/file_utils.dart b/packages/neon_framework/lib/src/utils/file_utils.dart new file mode 100644 index 00000000000..a71a1b5bee6 --- /dev/null +++ b/packages/neon_framework/lib/src/utils/file_utils.dart @@ -0,0 +1,21 @@ +import 'package:file_picker/file_picker.dart'; + +class FileUtils { + FileUtils._(); + + static Future loadFileWithPickDialog({ + bool withData = false, + bool allowMultiple = false, + FileType type = FileType.any, + }) async { + final result = await FilePicker.platform.pickFiles( + withData: withData, + allowMultiple: allowMultiple, + type: type, + ); + + return result; + } + + static Future pickDirectory() async => FilePicker.platform.getDirectoryPath(); +} diff --git a/packages/neon_framework/lib/src/utils/global_popups.dart b/packages/neon_framework/lib/src/utils/global_popups.dart index 3ed8068e32a..03ad0313b05 100644 --- a/packages/neon_framework/lib/src/utils/global_popups.dart +++ b/packages/neon_framework/lib/src/utils/global_popups.dart @@ -3,14 +3,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/blocs/first_launch.dart'; import 'package:neon_framework/src/blocs/next_push.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; import 'package:neon_framework/src/pages/settings.dart'; import 'package:neon_framework/src/platform/platform.dart'; import 'package:neon_framework/src/router.dart'; +import 'package:neon_framework/src/sync/widgets/resolve_sync_conflicts_dialog.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/src/widgets/error.dart'; +import 'package:provider/provider.dart'; +import 'package:synchronize/synchronize.dart'; /// Singleton class managing global popups. @internal @@ -62,10 +68,11 @@ class GlobalPopups { final globalOptions = NeonProvider.of(context); final firstLaunchBloc = NeonProvider.of(context); final nextPushBloc = NeonProvider.of(context); + final syncBloc = NeonProvider.of(context); if (NeonPlatform.instance.canUsePushNotifications) { _subscriptions.addAll([ firstLaunchBloc.onFirstLaunch.listen((_) { - assert(context.mounted, 'Context should be mounted'); + assert(_context.mounted, 'Context should be mounted'); if (!globalOptions.pushNotificationsEnabled.enabled) { return; } @@ -95,5 +102,31 @@ class GlobalPopups { }), ]); } + _subscriptions.addAll([ + syncBloc.errors.listen((error) { + assert(_context.mounted, 'Context should be mounted'); + NeonError.showSnackbar(_context, error); + }), + syncBloc.conflicts.listen((conflicts) async { + assert(_context.mounted, 'Context should be mounted'); + + final providers = NeonProvider.of(context).getAppsBlocFor(conflicts.account).appBlocProviders; + final result = await showDialog>( + context: _context, + builder: (context) => MultiProvider( + providers: providers, + child: NeonResolveSyncConflictsDialog(conflicts: conflicts), + ), + ); + if (result == null) { + return; + } + + await syncBloc.syncMapping( + conflicts.mapping, + solutions: result, + ); + }), + ]); } } diff --git a/packages/neon_framework/lib/src/utils/sync_mapping_options.dart b/packages/neon_framework/lib/src/utils/sync_mapping_options.dart new file mode 100644 index 00000000000..56ab710e034 --- /dev/null +++ b/packages/neon_framework/lib/src/utils/sync_mapping_options.dart @@ -0,0 +1,31 @@ +import 'package:meta/meta.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/settings.dart'; +import 'package:neon_framework/src/storage/keys.dart'; + +@internal +@immutable +class SyncMappingOptions extends OptionsCollection { + SyncMappingOptions(super.storage); + + @override + late final List> options = [ + automaticSync, + ]; + + late final automaticSync = ToggleOption( + storage: storage, + key: SyncMappingOptionKeys.automaticSync, + label: (context) => NeonLocalizations.of(context).syncOptionsAutomaticSync, + defaultValue: true, + ); +} + +enum SyncMappingOptionKeys implements Storable { + automaticSync._('automatic-sync'); + + const SyncMappingOptionKeys._(this.value); + + @override + final String value; +} diff --git a/packages/neon_framework/lib/src/widgets/adaptive_widgets/list_tile.dart b/packages/neon_framework/lib/src/widgets/adaptive_widgets/list_tile.dart index f84183689a3..480111e9cfd 100644 --- a/packages/neon_framework/lib/src/widgets/adaptive_widgets/list_tile.dart +++ b/packages/neon_framework/lib/src/widgets/adaptive_widgets/list_tile.dart @@ -16,6 +16,7 @@ class AdaptiveListTile extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }) : additionalInfo = null; @@ -30,6 +31,7 @@ class AdaptiveListTile extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }) : subtitle = additionalInfo; @@ -76,6 +78,18 @@ class AdaptiveListTile extends StatelessWidget { /// {@endtemplate} final FutureOr Function()? onTap; + /// {@template neon_framework.AdaptiveListTile.onLongPress} + /// The [onLongPress] function is called when a user long presses on the[AdaptiveListTile]. + /// If left `null`, the [AdaptiveListTile] will not react to long presses. + /// + /// If the platform is a Cupertino one and this is a `Future Function()`, + /// then the [AdaptiveListTile] remains activated until the returned future is + /// awaited. This is according to iOS behavior. + /// However, if this function is a `void Function()`, then the tile is active + /// only for the duration of invocation. + /// {@endtemplate} + final FutureOr Function()? onLongPress; + /// {@template neon_framework.AdaptiveListTile.enabled} /// Whether this list tile is interactive. /// diff --git a/packages/neon_framework/lib/src/widgets/drawer.dart b/packages/neon_framework/lib/src/widgets/drawer.dart index 16219f22c71..fb3842c3449 100644 --- a/packages/neon_framework/lib/src/widgets/drawer.dart +++ b/packages/neon_framework/lib/src/widgets/drawer.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/l10n/localizations.dart'; import 'package:neon_framework/src/bloc/result.dart'; @@ -34,6 +35,21 @@ class _NeonDrawerState extends State { List? _apps; int? _activeApp; + final _extraDestinations = { + NavigationDrawerDestination( + icon: const Icon(MdiIcons.cloudSync), + label: Builder( + builder: (context) => Text(NeonLocalizations.of(context).sync), + ), + ): (context) => const SyncRoute().go(context), + NavigationDrawerDestination( + icon: const Icon(Icons.settings), + label: Builder( + builder: (context) => Text(NeonLocalizations.of(context).settings), + ), + ): (context) => const SettingsRoute().go(context), + }; + @override void initState() { super.initState(); @@ -53,8 +69,9 @@ class _NeonDrawerState extends State { Scaffold.maybeOf(context)?.closeDrawer(); // selected item is not a registered app like the SettingsPage - if (index >= (_apps?.length ?? 0)) { - const SettingsRoute().go(context); + final appsCount = _apps?.length ?? 0; + if (index >= appsCount) { + _extraDestinations.values.elementAt(index - appsCount)(context); return; } @@ -80,10 +97,7 @@ class _NeonDrawerState extends State { children: [ const NeonDrawerHeader(), ...?appDestinations, - NavigationDrawerDestination( - icon: const Icon(Icons.settings), - label: Text(NeonLocalizations.of(context).settings), - ), + ..._extraDestinations.keys, ], ); diff --git a/packages/neon_framework/lib/src/widgets/sync_status_icon.dart b/packages/neon_framework/lib/src/widgets/sync_status_icon.dart new file mode 100644 index 00000000000..492e4c4b07c --- /dev/null +++ b/packages/neon_framework/lib/src/widgets/sync_status_icon.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/theme/colors.dart'; + +class SyncStatusIcon extends StatelessWidget { + const SyncStatusIcon({ + required this.status, + this.size, + super.key, + }); + + final SyncMappingStatus status; + final double? size; + + @override + Widget build(BuildContext context) { + final (icon, color, semanticLabel) = switch (status) { + SyncMappingStatus.unknown => ( + MdiIcons.cloudQuestion, + NcColors.error, + NeonLocalizations.of(context).syncOptionsStatusUnknown, + ), + SyncMappingStatus.incomplete => ( + MdiIcons.cloudSync, + NcColors.warning, + NeonLocalizations.of(context).syncOptionsStatusIncomplete, + ), + SyncMappingStatus.complete => ( + MdiIcons.cloudCheck, + NcColors.success, + NeonLocalizations.of(context).syncOptionsStatusComplete, + ), + }; + + return Icon( + icon, + color: color, + size: size, + semanticLabel: semanticLabel, + ); + } +} diff --git a/packages/neon_framework/lib/sync.dart b/packages/neon_framework/lib/sync.dart new file mode 100644 index 00000000000..dbd946cb05f --- /dev/null +++ b/packages/neon_framework/lib/sync.dart @@ -0,0 +1,4 @@ +export 'package:neon_framework/src/sync/models/conflicts.dart'; +export 'package:neon_framework/src/sync/models/implementation.dart'; +export 'package:neon_framework/src/sync/models/mapping.dart'; +export 'package:synchronize/synchronize.dart'; diff --git a/packages/neon_framework/lib/utils.dart b/packages/neon_framework/lib/utils.dart index 48177b6312e..61d53764432 100644 --- a/packages/neon_framework/lib/utils.dart +++ b/packages/neon_framework/lib/utils.dart @@ -2,6 +2,7 @@ export 'package:neon_framework/l10n/localizations.dart'; export 'package:neon_framework/src/utils/app_route.dart'; export 'package:neon_framework/src/utils/dialog.dart'; export 'package:neon_framework/src/utils/exceptions.dart'; +export 'package:neon_framework/src/utils/file_utils.dart'; export 'package:neon_framework/src/utils/findable.dart'; export 'package:neon_framework/src/utils/hex_color.dart'; export 'package:neon_framework/src/utils/provider.dart'; diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml index 1587a04ba70..f9ea0a010b6 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -52,6 +52,10 @@ dependencies: sqflite: ^2.3.0 sqflite_common_ffi: ^2.3.2 sqflite_common_ffi_web: ^0.4.2+3 + synchronize: + git: + url: https://github.com/nextcloud/neon + path: packages/synchronize unifiedpush: ^5.0.0 unifiedpush_android: ^2.0.0 universal_io: ^2.0.0 diff --git a/packages/neon_framework/pubspec_overrides.yaml b/packages/neon_framework/pubspec_overrides.yaml index 41d5fd16a1a..eb2b151e17d 100644 --- a/packages/neon_framework/pubspec_overrides.yaml +++ b/packages/neon_framework/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../dynamite/dynamite_runtime @@ -8,3 +8,5 @@ dependency_overrides: path: ../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize