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