Browse Source

feat(neon): Implement syncing

Signed-off-by: jld3103 <jld3103yt@gmail.com>
pull/600/head
jld3103 1 year ago
parent
commit
be4dbb7073
No known key found for this signature in database
GPG Key ID: 9062417B9E8EB7B3
  1. 7
      packages/app/pubspec.lock
  2. 4
      packages/app/pubspec_overrides.yaml
  3. 28
      packages/neon/neon/lib/l10n/en.arb
  4. 96
      packages/neon/neon/lib/l10n/localizations.dart
  5. 50
      packages/neon/neon/lib/l10n/localizations_en.dart
  6. 6
      packages/neon/neon/lib/neon.dart
  7. 8
      packages/neon/neon/lib/src/blocs/apps.dart
  8. 272
      packages/neon/neon/lib/src/blocs/sync.dart
  9. 5
      packages/neon/neon/lib/src/models/app_implementation.dart
  10. 156
      packages/neon/neon/lib/src/pages/app_implementation_settings.dart
  11. 74
      packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart
  12. 4
      packages/neon/neon/lib/src/pages/settings.dart
  13. 72
      packages/neon/neon/lib/src/pages/sync_mapping_settings.dart
  14. 6
      packages/neon/neon/lib/src/router.dart
  15. 3
      packages/neon/neon/lib/src/settings/models/storage.dart
  16. 3
      packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart
  17. 18
      packages/neon/neon/lib/src/sync/models/conflicts.dart
  18. 43
      packages/neon/neon/lib/src/sync/models/implementation.dart
  19. 23
      packages/neon/neon/lib/src/sync/models/mapping.dart
  20. 115
      packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart
  21. 49
      packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart
  22. 52
      packages/neon/neon/lib/src/utils/file_utils.dart
  23. 35
      packages/neon/neon/lib/src/utils/global_popups.dart
  24. 32
      packages/neon/neon/lib/src/utils/save_file.dart
  25. 30
      packages/neon/neon/lib/src/utils/sync_mapping_options.dart
  26. 14
      packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart
  27. 44
      packages/neon/neon/lib/src/widgets/sync_status_icon.dart
  28. 4
      packages/neon/neon/lib/sync.dart
  29. 1
      packages/neon/neon/lib/utils.dart
  30. 4
      packages/neon/neon/pubspec.yaml
  31. 4
      packages/neon/neon/pubspec_overrides.yaml
  32. 4
      packages/neon/neon_dashboard/pubspec_overrides.yaml
  33. 4
      packages/neon/neon_files/pubspec_overrides.yaml
  34. 4
      packages/neon/neon_news/pubspec_overrides.yaml
  35. 4
      packages/neon/neon_notes/pubspec_overrides.yaml
  36. 4
      packages/neon/neon_notifications/pubspec_overrides.yaml

7
packages/app/pubspec.lock

@ -1174,6 +1174,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.1" version: "0.3.1"
synchronize:
dependency: "direct overridden"
description:
path: "../synchronize"
relative: true
source: path
version: "1.0.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:

4
packages/app/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_files,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,neon_lints,neon_dashboard # melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_dashboard,neon_files,neon_lints,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../dynamite/dynamite_runtime path: ../dynamite/dynamite_runtime
@ -22,3 +22,5 @@ dependency_overrides:
path: ../nextcloud path: ../nextcloud
sort_box: sort_box:
path: ../sort_box path: ../sort_box
synchronize:
path: ../synchronize

28
packages/neon/neon/lib/l10n/en.arb

@ -84,6 +84,10 @@
"actionShowSlashHide": "Show/Hide", "actionShowSlashHide": "Show/Hide",
"actionExit": "Exit", "actionExit": "Exit",
"actionContinue": "Continue", "actionContinue": "Continue",
"actionPrevious": "Previous",
"actionNext": "Next",
"actionCancel": "Cancel",
"actionFinish": "Finish",
"firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications",
"nextPushSupported": "NextPush is supported!", "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.", "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.",
@ -124,6 +128,7 @@
"optionsCategoryStartup": "Startup", "optionsCategoryStartup": "Startup",
"optionsCategorySystemTray": "System tray", "optionsCategorySystemTray": "System tray",
"optionsCategoryNavigation": "Navigation", "optionsCategoryNavigation": "Navigation",
"optionsCategorySync": "Synchronization",
"optionsSortOrderAscending": "Ascending", "optionsSortOrderAscending": "Ascending",
"optionsSortOrderDescending": "Descending", "optionsSortOrderDescending": "Descending",
"globalOptionsThemeMode": "Theme mode", "globalOptionsThemeMode": "Theme mode",
@ -180,5 +185,26 @@
"accountOptionsAutomatic": "Automatic", "accountOptionsAutomatic": "Automatic",
"licenses": "Licenses", "licenses": "Licenses",
"sourceCode": "Source code", "sourceCode": "Source code",
"issueTracker": "Report a bug or request a feature" "issueTracker": "Report a bug or request a feature",
"syncOptionsAdd": "Add synchronization",
"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"
}
}
}
} }

96
packages/neon/neon/lib/l10n/localizations.dart

@ -311,6 +311,30 @@ abstract class NeonLocalizations {
/// **'Continue'** /// **'Continue'**
String get actionContinue; String get actionContinue;
/// 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 @actionCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get actionCancel;
/// No description provided for @actionFinish.
///
/// In en, this message translates to:
/// **'Finish'**
String get actionFinish;
/// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -467,6 +491,12 @@ abstract class NeonLocalizations {
/// **'Navigation'** /// **'Navigation'**
String get optionsCategoryNavigation; String get optionsCategoryNavigation;
/// No description provided for @optionsCategorySync.
///
/// In en, this message translates to:
/// **'Synchronization'**
String get optionsCategorySync;
/// No description provided for @optionsSortOrderAscending. /// No description provided for @optionsSortOrderAscending.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -688,6 +718,72 @@ abstract class NeonLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Report a bug or request a feature'** /// **'Report a bug or request a feature'**
String get issueTracker; String get issueTracker;
/// No description provided for @syncOptionsAdd.
///
/// In en, this message translates to:
/// **'Add synchronization'**
String get syncOptionsAdd;
/// 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<NeonLocalizations> { class _NeonLocalizationsDelegate extends LocalizationsDelegate<NeonLocalizations> {

50
packages/neon/neon/lib/l10n/localizations_en.dart

@ -147,6 +147,18 @@ class NeonLocalizationsEn extends NeonLocalizations {
@override @override
String get actionContinue => 'Continue'; String get actionContinue => 'Continue';
@override
String get actionPrevious => 'Previous';
@override
String get actionNext => 'Next';
@override
String get actionCancel => 'Cancel';
@override
String get actionFinish => 'Finish';
@override @override
String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications';
@ -230,6 +242,9 @@ class NeonLocalizationsEn extends NeonLocalizations {
@override @override
String get optionsCategoryNavigation => 'Navigation'; String get optionsCategoryNavigation => 'Navigation';
@override
String get optionsCategorySync => 'Synchronization';
@override @override
String get optionsSortOrderAscending => 'Ascending'; String get optionsSortOrderAscending => 'Ascending';
@ -345,4 +360,39 @@ class NeonLocalizationsEn extends NeonLocalizations {
@override @override
String get issueTracker => 'Report a bug or request a feature'; String get issueTracker => 'Report a bug or request a feature';
@override
String get syncOptionsAdd => 'Add 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';
}
} }

6
packages/neon/neon/lib/neon.dart

@ -7,6 +7,7 @@ import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/first_launch.dart';
import 'package:neon/src/blocs/next_push.dart'; import 'package:neon/src/blocs/next_push.dart';
import 'package:neon/src/blocs/push_notifications.dart'; import 'package:neon/src/blocs/push_notifications.dart';
import 'package:neon/src/blocs/sync.dart';
import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/models/disposable.dart'; import 'package:neon/src/models/disposable.dart';
@ -66,6 +67,10 @@ Future<void> runNeon({
globalOptions, globalOptions,
disabled: nextPushDisabled, disabled: nextPushDisabled,
); );
final syncBloc = SyncBloc(
accountsBloc,
appImplementations,
);
runApp( runApp(
MultiProvider( MultiProvider(
@ -74,6 +79,7 @@ Future<void> runNeon({
NeonProvider<AccountsBloc>.value(value: accountsBloc), NeonProvider<AccountsBloc>.value(value: accountsBloc),
NeonProvider<FirstLaunchBloc>.value(value: firstLaunchBloc), NeonProvider<FirstLaunchBloc>.value(value: firstLaunchBloc),
NeonProvider<NextPushBloc>.value(value: nextPushBloc), NeonProvider<NextPushBloc>.value(value: nextPushBloc),
NeonProvider<SyncBloc>.value(value: syncBloc),
Provider<Iterable<AppImplementation>>( Provider<Iterable<AppImplementation>>(
create: (final _) => appImplementations, create: (final _) => appImplementations,
dispose: (final _, final appImplementations) => appImplementations.disposeAll(), dispose: (final _, final appImplementations) => appImplementations.disposeAll(),

8
packages/neon/neon/lib/src/blocs/apps.dart

@ -243,9 +243,17 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates
/// Returns the active [Bloc] for the given [appImplementation]. /// Returns the active [Bloc] for the given [appImplementation].
/// ///
/// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache].
/// See [getAppBlocByID] for getting the [Bloc] by the [AppImplementation.id].
T getAppBloc<T extends Bloc>(final AppImplementation<T, dynamic> appImplementation) => T getAppBloc<T extends Bloc>(final AppImplementation<T, dynamic> appImplementation) =>
appImplementation.getBloc(_account); appImplementation.getBloc(_account);
/// 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<T extends Bloc>(final String appId) =>
_allAppImplementations.tryFind(appId)?.getBloc(_account) as T?;
/// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider. /// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider.
List<Provider<Bloc>> get appBlocProviders => List<Provider<Bloc>> get appBlocProviders =>
_allAppImplementations.map((final appImplementation) => appImplementation.blocProvider).toList(); _allAppImplementations.map((final appImplementation) => appImplementation.blocProvider).toList();

272
packages/neon/neon/lib/src/blocs/sync.dart

@ -0,0 +1,272 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:neon/blocs.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/models/storage.dart';
import 'package:neon/src/sync/models/conflicts.dart';
import 'package:neon/src/sync/models/implementation.dart';
import 'package:neon/src/sync/models/mapping.dart';
import 'package:neon/src/utils/sync_mapping_options.dart';
import 'package:rxdart/rxdart.dart';
import 'package:synchronize/synchronize.dart';
abstract interface class SyncBlocEvents {
/// Adds a new [mapping] that will be synced.
void addMapping(final SyncMapping<dynamic, dynamic> mapping);
/// Removes an existing [mapping] that will no longer be synced.
void removeMapping(final SyncMapping<dynamic, dynamic> mapping);
/// Explicitly trigger a sync for the [mapping].
/// [solutions] can be use to apply solutions for conflicts.
void syncMapping(
final SyncMapping<dynamic, dynamic> mapping, {
final Map<String, SyncConflictSolution> solutions = const {},
});
}
abstract interface class SyncBlocStates {
/// Map of [SyncMapping]s and their [SyncMappingStatus]es
BehaviorSubject<Map<SyncMapping<dynamic, dynamic>, SyncMappingStatus>> get mappingStatuses;
/// Stream of conflicts that have arisen during syncing.
Stream<SyncConflicts<dynamic, dynamic>> get conflicts;
}
class SyncBloc extends InteractiveBloc implements SyncBlocEvents, SyncBlocStates {
SyncBloc(
this._accountsBloc,
final Iterable<AppImplementation> appImplementations,
) {
_syncImplementations = appImplementations.map((final app) => app.syncImplementation).whereNotNull();
_timer = TimerBloc().registerTimer(const Duration(minutes: 1), refresh);
_loadMappings();
mappingStatuses.value.keys.forEach(_watchMapping);
unawaited(refresh());
}
final AccountsBloc _accountsBloc;
static const _storage = SingleValueStorage(StorageKeys.sync);
late final Iterable<SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>> _syncImplementations;
late final NeonTimer _timer;
final _conflictsController = StreamController<SyncConflicts<dynamic, dynamic>>();
final _watchControllers = <String, StreamController<void>>{};
final _syncMappingOptions = <String, 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<SyncConflicts<dynamic, dynamic>> conflicts = _conflictsController.stream.asBroadcastStream();
@override
final BehaviorSubject<Map<SyncMapping<dynamic, dynamic>, SyncMappingStatus>> mappingStatuses = BehaviorSubject();
@override
Future<void> refresh() async {
for (final mapping in mappingStatuses.value.keys) {
await _updateMapping(mapping);
}
}
@override
Future<void> addMapping(final SyncMapping<dynamic, dynamic> 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<void> removeMapping(final SyncMapping<dynamic, dynamic> mapping) async {
debugPrint('Removing mapping: $mapping');
mappingStatuses.add(Map.fromEntries(mappingStatuses.value.entries.where((final m) => m.key != mapping)));
mapping.dispose();
await _saveMappings();
}
@override
Future<void> syncMapping(
final SyncMapping<dynamic, dynamic> mapping, {
final Map<String, SyncConflictSolution> 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((final conflict) => conflict.skipped).isNotEmpty) {
_conflictsController.add(
SyncConflicts<dynamic, dynamic>(
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<void> _updateMapping(final SyncMapping<dynamic, dynamic> 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<SyncMappingStatus> _getMappingStatus(
final Account account,
final SyncMapping<dynamic, dynamic> 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 = <SyncMapping<dynamic, dynamic>>[];
if (_storage.hasValue()) {
final serializedMappings = (json.decode(_storage.getString()!) as Map<String, dynamic>)
.map((final key, final value) => MapEntry(key, (value as List).map((final e) => e as Map<String, dynamic>)));
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<void> _saveMappings() async {
debugPrint('Saving mappings');
final serializedMappings = <String, List<Map<String, dynamic>>>{};
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(final SyncMapping<dynamic, dynamic> mapping) {
final syncImplementation = _syncImplementations.find(mapping.appId);
if (_watchControllers.containsKey(syncImplementation.getMappingId(mapping))) {
return;
}
// ignore: close_sinks
final controller = StreamController<void>();
// Debounce is required to stop bulk operations flooding the sync and potentially creating race conditions.
controller.stream.debounceTime(const Duration(seconds: 1)).listen((final _) async {
await _updateMapping(mapping);
});
_watchControllers[syncImplementation.getMappingId(mapping)] = controller;
mapping.watch(() {
controller.add(null);
});
}
SyncMappingOptions getSyncMappingOptionsFor(final SyncMapping<dynamic, dynamic> mapping) {
final syncImplementation = _syncImplementations.find(mapping.appId);
final id = syncImplementation.getGlobalUniqueMappingId(mapping);
return _syncMappingOptions[id] ??= SyncMappingOptions(
AppStorage(StorageKeys.sync, id),
);
}
}

5
packages/neon/neon/lib/src/models/app_implementation.dart

@ -12,6 +12,8 @@ import 'package:neon/src/models/account_cache.dart';
import 'package:neon/src/models/disposable.dart'; import 'package:neon/src/models/disposable.dart';
import 'package:neon/src/settings/models/options_collection.dart'; import 'package:neon/src/settings/models/options_collection.dart';
import 'package:neon/src/settings/models/storage.dart'; import 'package:neon/src/settings/models/storage.dart';
import 'package:neon/src/sync/models/implementation.dart';
import 'package:neon/src/sync/models/mapping.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/widgets/drawer_destination.dart'; import 'package:neon/src/widgets/drawer_destination.dart';
import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/core.dart' as core;
@ -82,6 +84,9 @@ abstract class AppImplementation<T extends Bloc, R extends NextcloudAppOptions>
@protected @protected
T buildBloc(final Account account); T buildBloc(final Account account);
/// Optional [SyncImplementation] for this [AppImplementation].
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>? get syncImplementation => null;
/// The [Provider] building the bloc [T] the currently active account. /// The [Provider] building the bloc [T] the currently active account.
/// ///
/// Blocs will not be disposed on disposal of the provider. You must handle /// Blocs will not be disposed on disposal of the provider. You must handle

156
packages/neon/neon/lib/src/pages/app_implementation_settings.dart

@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/sync.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/pages/sync_mapping_settings.dart';
import 'package:neon/src/settings/widgets/custom_settings_tile.dart';
import 'package:neon/src/settings/widgets/option_settings_tile.dart';
import 'package:neon/src/settings/widgets/settings_category.dart';
import 'package:neon/src/settings/widgets/settings_list.dart';
import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/confirmation_dialog.dart';
import 'package:neon/src/widgets/account_selection_dialog.dart';
import 'package:neon/src/widgets/sync_status_icon.dart';
import 'package:neon/src/widgets/user_avatar.dart';
import 'package:provider/provider.dart';
@internal
class AppImplementationSettingsPage extends StatelessWidget {
const AppImplementationSettingsPage({
required this.appImplementation,
super.key,
});
final AppImplementation appImplementation;
@override
Widget build(final BuildContext context) {
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
final syncBloc = Provider.of<SyncBloc>(context, listen: false);
final appBar = AppBar(
title: Text(appImplementation.name(context)),
actions: [
IconButton(
onPressed: () async {
if (await showConfirmationDialog(
context,
NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)),
)) {
appImplementation.options.reset();
}
},
tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)),
icon: const Icon(MdiIcons.cogRefresh),
),
],
);
final body = SettingsList(
categories: [
for (final category in [...appImplementation.options.categories, null]) ...[
if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[
SettingsCategory(
title: Text(
category != null ? category.name(context) : NeonLocalizations.of(context).optionsCategoryOther,
),
tiles: [
for (final option
in appImplementation.options.options.where((final option) => option.category == category)) ...[
OptionSettingsTile(option: option),
],
],
),
],
],
if (appImplementation.syncImplementation != null) ...[
StreamBuilder(
stream: syncBloc.mappingStatuses,
builder: (final context, final mappingStatuses) => !mappingStatuses.hasData
? const SizedBox.shrink()
: SettingsCategory(
title: Text(NeonLocalizations.of(context).optionsCategorySync),
tiles: [
for (final mappingStatus in mappingStatuses.requireData.entries
.where((final mappingStatus) => mappingStatus.key.appId == appImplementation.id)) ...[
CustomSettingsTile(
title: Text(appImplementation.syncImplementation!.getMappingDisplayTitle(mappingStatus.key)),
subtitle:
Text(appImplementation.syncImplementation!.getMappingDisplaySubtitle(mappingStatus.key)),
leading: NeonUserAvatar(
account: accountsBloc.accounts.value
.singleWhere((final account) => account.id == 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<void>(
builder: (final context) => SyncMappingSettingsPage(
mapping: mappingStatus.key,
),
),
);
},
),
],
CustomSettingsTile(
title: ElevatedButton.icon(
onPressed: () async {
final account = await showDialog<Account>(
context: context,
builder: (final 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),
),
),
],
),
),
],
],
);
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: appBar,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: NeonDialogTheme.of(context).constraints,
child: body,
),
),
),
);
}
}

74
packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart

@ -1,74 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/widgets/option_settings_tile.dart';
import 'package:neon/src/settings/widgets/settings_category.dart';
import 'package:neon/src/settings/widgets/settings_list.dart';
import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/confirmation_dialog.dart';
@internal
class NextcloudAppSettingsPage extends StatelessWidget {
const NextcloudAppSettingsPage({
required this.appImplementation,
super.key,
});
final AppImplementation appImplementation;
@override
Widget build(final BuildContext context) {
final appBar = AppBar(
title: Text(appImplementation.name(context)),
actions: [
IconButton(
onPressed: () async {
if (await showConfirmationDialog(
context,
NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)),
)) {
appImplementation.options.reset();
}
},
tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)),
icon: const Icon(MdiIcons.cogRefresh),
),
],
);
final body = SettingsList(
categories: [
for (final category in [...appImplementation.options.categories, null]) ...[
if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[
SettingsCategory(
title: Text(
category != null ? category.name(context) : NeonLocalizations.of(context).optionsCategoryOther,
),
tiles: [
for (final option
in appImplementation.options.options.where((final option) => option.category == category)) ...[
OptionSettingsTile(option: option),
],
],
),
],
],
],
);
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: appBar,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: NeonDialogTheme.of(context).constraints,
child: body,
),
),
),
);
}
}

4
packages/neon/neon/lib/src/pages/settings.dart

@ -20,9 +20,9 @@ import 'package:neon/src/theme/branding.dart';
import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/adaptive.dart'; import 'package:neon/src/utils/adaptive.dart';
import 'package:neon/src/utils/confirmation_dialog.dart'; import 'package:neon/src/utils/confirmation_dialog.dart';
import 'package:neon/src/utils/file_utils.dart';
import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/utils/save_file.dart';
import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/error.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -250,7 +250,7 @@ class _SettingsPageState extends State<SettingsPage> {
final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json'; final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json';
final data = settingsExportHelper.exportToFile(); final data = settingsExportHelper.exportToFile();
await saveFileWithPickDialog(fileName, data); await FileUtils.saveFileWithPickDialog(fileName, data);
} catch (e, s) { } catch (e, s) {
debugPrint(e.toString()); debugPrint(e.toString());
debugPrint(s.toString()); debugPrint(s.toString());

72
packages/neon/neon/lib/src/pages/sync_mapping_settings.dart

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/blocs/sync.dart';
import 'package:neon/src/settings/widgets/option_settings_tile.dart';
import 'package:neon/src/settings/widgets/settings_category.dart';
import 'package:neon/src/settings/widgets/settings_list.dart';
import 'package:neon/src/sync/models/mapping.dart';
import 'package:neon/src/utils/confirmation_dialog.dart';
import 'package:provider/provider.dart';
class SyncMappingSettingsPage extends StatelessWidget {
const SyncMappingSettingsPage({
required this.mapping,
super.key,
});
final SyncMapping<dynamic, dynamic> mapping;
@override
Widget build(final BuildContext context) {
final syncBloc = Provider.of<SyncBloc>(context, listen: false);
final options = syncBloc.getSyncMappingOptionsFor(mapping);
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
onPressed: () async {
if (await showConfirmationDialog(
context,
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,
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,
),
],
),
],
),
);
}
}

6
packages/neon/neon/lib/src/router.dart

@ -10,13 +10,13 @@ import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/pages/account_settings.dart'; import 'package:neon/src/pages/account_settings.dart';
import 'package:neon/src/pages/app_implementation_settings.dart';
import 'package:neon/src/pages/home.dart'; import 'package:neon/src/pages/home.dart';
import 'package:neon/src/pages/login.dart'; import 'package:neon/src/pages/login.dart';
import 'package:neon/src/pages/login_check_account.dart'; import 'package:neon/src/pages/login_check_account.dart';
import 'package:neon/src/pages/login_check_server_status.dart'; import 'package:neon/src/pages/login_check_server_status.dart';
import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_flow.dart';
import 'package:neon/src/pages/login_qr_code.dart'; import 'package:neon/src/pages/login_qr_code.dart';
import 'package:neon/src/pages/nextcloud_app_settings.dart';
import 'package:neon/src/pages/route_not_found.dart'; import 'package:neon/src/pages/route_not_found.dart';
import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/pages/settings.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
@ -439,7 +439,7 @@ class _AddAccountCheckAccountRoute extends LoginCheckAccountRoute {
} }
/// {@template AppRoutes.NextcloudAppSettingsRoute} /// {@template AppRoutes.NextcloudAppSettingsRoute}
/// Route for the the [NextcloudAppSettingsPage]. /// Route for the the [AppImplementationSettingsPage].
/// {@endtemplate} /// {@endtemplate}
@immutable @immutable
class NextcloudAppSettingsRoute extends GoRouteData { class NextcloudAppSettingsRoute extends GoRouteData {
@ -456,7 +456,7 @@ class NextcloudAppSettingsRoute extends GoRouteData {
final appImplementations = NeonProvider.of<Iterable<AppImplementation>>(context); final appImplementations = NeonProvider.of<Iterable<AppImplementation>>(context);
final appImplementation = appImplementations.tryFind(appid)!; final appImplementation = appImplementations.tryFind(appid)!;
return NextcloudAppSettingsPage(appImplementation: appImplementation); return AppImplementationSettingsPage(appImplementation: appImplementation);
} }
} }

3
packages/neon/neon/lib/src/settings/models/storage.dart

@ -70,6 +70,9 @@ enum StorageKeys implements Storable {
/// The key for the `Account`s and their `AccountSpecificOptions`. /// The key for the `Account`s and their `AccountSpecificOptions`.
accounts._('accounts'), accounts._('accounts'),
/// The key for the `SyncImplementation`s.
sync._('sync'),
/// The key for the `GlobalOptions`. /// The key for the `GlobalOptions`.
global._('global'), global._('global'),

3
packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart

@ -13,6 +13,7 @@ class CustomSettingsTile extends SettingsTile {
this.leading, this.leading,
this.trailing, this.trailing,
this.onTap, this.onTap,
this.onLongPress,
super.key, super.key,
}); });
@ -21,6 +22,7 @@ class CustomSettingsTile extends SettingsTile {
final Widget? leading; final Widget? leading;
final Widget? trailing; final Widget? trailing;
final FutureOr<void> Function()? onTap; final FutureOr<void> Function()? onTap;
final FutureOr<void> Function()? onLongPress;
@override @override
Widget build(final BuildContext context) => AdaptiveListTile( Widget build(final BuildContext context) => AdaptiveListTile(
@ -29,5 +31,6 @@ class CustomSettingsTile extends SettingsTile {
leading: leading, leading: leading,
trailing: trailing, trailing: trailing,
onTap: onTap, onTap: onTap,
onLongPress: onLongPress,
); );
} }

18
packages/neon/neon/lib/src/sync/models/conflicts.dart

@ -0,0 +1,18 @@
import 'package:neon/src/models/account.dart';
import 'package:neon/src/sync/models/implementation.dart';
import 'package:neon/src/sync/models/mapping.dart';
import 'package:synchronize/synchronize.dart';
class SyncConflicts<T1, T2> {
SyncConflicts(
this.account,
this.implementation,
this.mapping,
this.conflicts,
);
final Account account;
final SyncImplementation<SyncMapping<T1, T2>, T1, T2> implementation;
final SyncMapping<T1, T2> mapping;
final List<SyncConflict<T1, T2>> conflicts;
}

43
packages/neon/neon/lib/src/sync/models/implementation.dart

@ -0,0 +1,43 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/sync/models/mapping.dart';
import 'package:synchronize/synchronize.dart';
@immutable
abstract interface class SyncImplementation<S extends SyncMapping<T1, T2>, T1, T2> {
String get appId;
FutureOr<SyncSources<T1, T2>> getSources(final Account account, final S mapping);
Map<String, dynamic> serializeMapping(final S mapping);
S deserializeMapping(final Map<String, dynamic> json);
FutureOr<S?> addMapping(final BuildContext context, final Account account);
String getMappingDisplayTitle(final S mapping);
String getMappingDisplaySubtitle(final S mapping);
String getMappingId(final S mapping);
Widget getConflictDetailsLocal(final BuildContext context, final T2 object);
Widget getConflictDetailsRemote(final BuildContext context, final T1 object);
}
extension SyncImplementationGlobalUniqueMappingId
on SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic> {
String getGlobalUniqueMappingId(final SyncMapping<dynamic, dynamic> mapping) =>
'${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}';
}
extension SyncImplementationFind on Iterable<SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>> {
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>? tryFind(final String appId) =>
singleWhereOrNull((final syncImplementation) => appId == syncImplementation.appId);
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic> find(final String appId) => tryFind(appId)!;
}

23
packages/neon/neon/lib/src/sync/models/mapping.dart

@ -0,0 +1,23 @@
import 'package:meta/meta.dart';
import 'package:synchronize/synchronize.dart';
abstract interface class SyncMapping<T1, T2> {
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(final void Function() onUpdated) {}
@mustBeOverridden
void dispose() {}
@override
String toString() => 'SyncMapping(accountId: $accountId, appId: $appId)';
}
enum SyncMappingStatus {
unknown,
incomplete,
complete,
}

115
packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/sync/models/conflicts.dart';
import 'package:neon/src/sync/widgets/sync_conflict_card.dart';
import 'package:neon/src/theme/dialog.dart';
import 'package:synchronize/synchronize.dart';
class NeonResolveSyncConflictsDialog<T1, T2> extends StatefulWidget {
const NeonResolveSyncConflictsDialog({
required this.conflicts,
super.key,
});
final SyncConflicts<T1, T2> conflicts;
@override
State<NeonResolveSyncConflictsDialog<T1, T2>> createState() => _NeonResolveSyncConflictsDialogState<T1, T2>();
}
class _NeonResolveSyncConflictsDialogState<T1, T2> extends State<NeonResolveSyncConflictsDialog<T1, T2>> {
var _index = 0;
final _solutions = <String, SyncConflictSolution>{};
SyncConflict<T1, T2> get conflict => widget.conflicts.conflicts[_index];
SyncConflictSolution? get selectedSolution => _solutions[conflict.id];
void onSolution(final SyncConflictSolution solution) {
setState(() {
_solutions[conflict.id] = solution;
});
}
bool get isFirst => _index == 0;
bool get isLast => _index == widget.conflicts.conflicts.length - 1;
@override
Widget build(final BuildContext context) {
final body = Column(
children: [
Text(
NeonLocalizations.of(context).syncResolveConflictsTitle(
widget.conflicts.conflicts.length,
NeonLocalizations.of(context).appImplementationName(widget.conflicts.implementation.appId),
),
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,
),
),
);
}
}

49
packages/neon/neon/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(final 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,
],
),
),
);
}

52
packages/neon/neon/lib/src/utils/file_utils.dart

@ -0,0 +1,52 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
import 'package:neon/src/platform/platform.dart';
import 'package:universal_io/io.dart';
class FileUtils {
FileUtils._();
/// Displays a dialog for selecting a location where to save a file with the [data] content.
///
/// Set the the suggested [fileName] to use when saving the file.
///
/// Returns the path of the saved file or `null` if the operation was cancelled.
static Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async {
if (NeonPlatform.instance.shouldUseFileDialog) {
// TODO: https://github.com/nextcloud/neon/issues/8
return FlutterFileDialog.saveFile(
params: SaveFileDialogParams(
data: data,
fileName: fileName,
),
);
} else {
final result = await FilePicker.platform.saveFile(
fileName: fileName,
);
if (result != null) {
await File(result).writeAsBytes(data);
}
return result;
}
}
static Future<FilePickerResult?> loadFileWithPickDialog({
final bool withData = false,
final bool allowMultiple = false,
final FileType type = FileType.any,
}) async {
final result = await FilePicker.platform.pickFiles(
withData: withData,
allowMultiple: allowMultiple,
type: type,
);
return result;
}
static Future<String?> pickDirectory() async => FilePicker.platform.getDirectoryPath();
}

35
packages/neon/neon/lib/src/utils/global_popups.dart

@ -3,13 +3,19 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart'; import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/first_launch.dart';
import 'package:neon/src/blocs/next_push.dart'; import 'package:neon/src/blocs/next_push.dart';
import 'package:neon/src/blocs/sync.dart';
import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/pages/settings.dart';
import 'package:neon/src/platform/platform.dart'; import 'package:neon/src/platform/platform.dart';
import 'package:neon/src/router.dart'; import 'package:neon/src/router.dart';
import 'package:neon/src/sync/widgets/resolve_sync_conflicts_dialog.dart';
import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/widgets/error.dart';
import 'package:provider/provider.dart';
import 'package:synchronize/synchronize.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
/// Singleton class managing global popups. /// Singleton class managing global popups.
@ -62,10 +68,11 @@ class GlobalPopups {
final globalOptions = NeonProvider.of<GlobalOptions>(context); final globalOptions = NeonProvider.of<GlobalOptions>(context);
final firstLaunchBloc = NeonProvider.of<FirstLaunchBloc>(context); final firstLaunchBloc = NeonProvider.of<FirstLaunchBloc>(context);
final nextPushBloc = NeonProvider.of<NextPushBloc>(context); final nextPushBloc = NeonProvider.of<NextPushBloc>(context);
final syncBloc = NeonProvider.of<SyncBloc>(context);
if (NeonPlatform.instance.canUsePushNotifications) { if (NeonPlatform.instance.canUsePushNotifications) {
_subscriptions.addAll([ _subscriptions.addAll([
firstLaunchBloc.onFirstLaunch.listen((final _) { firstLaunchBloc.onFirstLaunch.listen((final _) {
assert(context.mounted, 'Context should be mounted'); assert(_context.mounted, 'Context should be mounted');
if (!globalOptions.pushNotificationsEnabled.enabled) { if (!globalOptions.pushNotificationsEnabled.enabled) {
return; return;
} }
@ -116,5 +123,31 @@ class GlobalPopups {
}), }),
]); ]);
} }
_subscriptions.addAll([
syncBloc.errors.listen((final error) {
assert(_context.mounted, 'Context should be mounted');
NeonError.showSnackbar(_context, error);
}),
syncBloc.conflicts.listen((final conflicts) async {
assert(_context.mounted, 'Context should be mounted');
final providers = NeonProvider.of<AccountsBloc>(context).getAppsBlocFor(conflicts.account).appBlocProviders;
final result = await showDialog<Map<String, SyncConflictSolution>>(
context: _context,
builder: (final context) => MultiProvider(
providers: providers,
child: NeonResolveSyncConflictsDialog(conflicts: conflicts),
),
);
if (result == null) {
return;
}
await syncBloc.syncMapping(
conflicts.mapping,
solutions: result,
);
}),
]);
} }
} }

32
packages/neon/neon/lib/src/utils/save_file.dart

@ -1,32 +0,0 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
import 'package:neon/src/platform/platform.dart';
import 'package:universal_io/io.dart';
/// Displays a dialog for selecting a location where to save a file with the [data] content.
///
/// Set the the suggested [fileName] to use when saving the file.
///
/// Returns the path of the saved file or `null` if the operation was cancelled.
Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async {
if (NeonPlatform.instance.shouldUseFileDialog) {
// TODO: https://github.com/nextcloud/neon/issues/8
return FlutterFileDialog.saveFile(
params: SaveFileDialogParams(
data: data,
fileName: fileName,
),
);
} else {
final result = await FilePicker.platform.saveFile(
fileName: fileName,
);
if (result != null) {
await File(result).writeAsBytes(data);
}
return result;
}
}

30
packages/neon/neon/lib/src/utils/sync_mapping_options.dart

@ -0,0 +1,30 @@
import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/settings.dart';
@internal
@immutable
class SyncMappingOptions extends OptionsCollection {
SyncMappingOptions(super.storage);
@override
late final List<Option<dynamic>> options = [
automaticSync,
];
late final automaticSync = ToggleOption(
storage: storage,
key: SyncMappingOptionKeys.automaticSync,
label: (final context) => NeonLocalizations.of(context).syncOptionsAutomaticSync,
defaultValue: true,
);
}
enum SyncMappingOptionKeys implements Storable {
automaticSync._('automatic-sync');
const SyncMappingOptionKeys._(this.value);
@override
final String value;
}

14
packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart

@ -16,6 +16,7 @@ class AdaptiveListTile extends StatelessWidget {
this.leading, this.leading,
this.trailing, this.trailing,
this.onTap, this.onTap,
this.onLongPress,
super.key, super.key,
}) : additionalInfo = null; }) : additionalInfo = null;
@ -30,6 +31,7 @@ class AdaptiveListTile extends StatelessWidget {
this.leading, this.leading,
this.trailing, this.trailing,
this.onTap, this.onTap,
this.onLongPress,
super.key, super.key,
}) : subtitle = additionalInfo; }) : subtitle = additionalInfo;
@ -76,6 +78,18 @@ class AdaptiveListTile extends StatelessWidget {
/// {@endtemplate} /// {@endtemplate}
final FutureOr<void> Function()? onTap; final FutureOr<void> Function()? onTap;
/// {@template neon.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<void> 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<void> Function()? onLongPress;
/// {@template neon.AdaptiveListTile.enabled} /// {@template neon.AdaptiveListTile.enabled}
/// Whether this list tile is interactive. /// Whether this list tile is interactive.
/// ///

44
packages/neon/neon/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/l10n/localizations.dart';
import 'package:neon/src/sync/models/mapping.dart';
import 'package:neon/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(final 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,
);
}
}

4
packages/neon/neon/lib/sync.dart

@ -0,0 +1,4 @@
export 'package:neon/src/sync/models/conflicts.dart';
export 'package:neon/src/sync/models/implementation.dart';
export 'package:neon/src/sync/models/mapping.dart';
export 'package:synchronize/synchronize.dart';

1
packages/neon/neon/lib/utils.dart

@ -2,6 +2,7 @@ export 'package:neon/l10n/localizations.dart';
export 'package:neon/src/utils/app_route.dart'; export 'package:neon/src/utils/app_route.dart';
export 'package:neon/src/utils/confirmation_dialog.dart'; export 'package:neon/src/utils/confirmation_dialog.dart';
export 'package:neon/src/utils/exceptions.dart'; export 'package:neon/src/utils/exceptions.dart';
export 'package:neon/src/utils/file_utils.dart';
export 'package:neon/src/utils/hex_color.dart'; export 'package:neon/src/utils/hex_color.dart';
export 'package:neon/src/utils/provider.dart'; export 'package:neon/src/utils/provider.dart';
export 'package:neon/src/utils/rename_dialog.dart'; export 'package:neon/src/utils/rename_dialog.dart';

4
packages/neon/neon/pubspec.yaml

@ -50,6 +50,10 @@ dependencies:
path: packages/sort_box path: packages/sort_box
sqflite: ^2.0.0 sqflite: ^2.0.0
sqflite_common_ffi: ^2.2.8-2 sqflite_common_ffi: ^2.2.8-2
synchronize:
git:
url: https://github.com/nextcloud/neon
path: packages/synchronize
tray_manager: ^0.2.0 tray_manager: ^0.2.0
unifiedpush: ^5.0.0 unifiedpush: ^5.0.0
unifiedpush_android: ^2.0.0 unifiedpush_android: ^2.0.0

4
packages/neon/neon/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,nextcloud,sort_box,neon_lints # melos_managed_dependency_overrides: dynamite_runtime,nextcloud,sort_box,neon_lints,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../../dynamite/dynamite_runtime path: ../../dynamite/dynamite_runtime
@ -8,3 +8,5 @@ dependency_overrides:
path: ../../nextcloud path: ../../nextcloud
sort_box: sort_box:
path: ../../sort_box path: ../../sort_box
synchronize:
path: ../../synchronize

4
packages/neon/neon_dashboard/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box # melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../../dynamite/dynamite_runtime path: ../../dynamite/dynamite_runtime
@ -10,3 +10,5 @@ dependency_overrides:
path: ../../nextcloud path: ../../nextcloud
sort_box: sort_box:
path: ../../sort_box path: ../../sort_box
synchronize:
path: ../../synchronize

4
packages/neon/neon_files/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,nextcloud,sort_box,neon_lints # melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,nextcloud,sort_box,neon_lints,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../../dynamite/dynamite_runtime path: ../../dynamite/dynamite_runtime
@ -12,3 +12,5 @@ dependency_overrides:
path: ../../nextcloud path: ../../nextcloud
sort_box: sort_box:
path: ../../sort_box path: ../../sort_box
synchronize:
path: ../../synchronize

4
packages/neon/neon_news/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints # melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../../dynamite/dynamite_runtime path: ../../dynamite/dynamite_runtime
@ -10,3 +10,5 @@ dependency_overrides:
path: ../../nextcloud path: ../../nextcloud
sort_box: sort_box:
path: ../../sort_box path: ../../sort_box
synchronize:
path: ../../synchronize

4
packages/neon/neon_notes/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints # melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../../dynamite/dynamite_runtime path: ../../dynamite/dynamite_runtime
@ -10,3 +10,5 @@ dependency_overrides:
path: ../../nextcloud path: ../../nextcloud
sort_box: sort_box:
path: ../../sort_box path: ../../sort_box
synchronize:
path: ../../synchronize

4
packages/neon/neon_notifications/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints # melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize
dependency_overrides: dependency_overrides:
dynamite_runtime: dynamite_runtime:
path: ../../dynamite/dynamite_runtime path: ../../dynamite/dynamite_runtime
@ -10,3 +10,5 @@ dependency_overrides:
path: ../../nextcloud path: ../../nextcloud
sort_box: sort_box:
path: ../../sort_box path: ../../sort_box
synchronize:
path: ../../synchronize

Loading…
Cancel
Save