jld3103
1 year ago
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