36 changed files with 1162 additions and 120 deletions
			
			
		| @ -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), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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; | ||||
| } | ||||
| @ -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)!; | ||||
| } | ||||
| @ -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, | ||||
| } | ||||
| @ -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, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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, | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
| } | ||||
| @ -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(); | ||||
| } | ||||
| @ -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; | ||||
|   } | ||||
| } | ||||
| @ -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; | ||||
| } | ||||
| @ -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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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'; | ||||
					Loading…
					
					
				
		Reference in new issue