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