547 lines
20 KiB
Dart
547 lines
20 KiB
Dart
/*
|
|
* Copyright (c) 2024-2026 Fabian Freund.
|
|
*
|
|
* This file is part of WebLibre
|
|
* (see https://weblibre.eu).
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:weblibre/core/logger.dart';
|
|
import 'package:weblibre/core/providers/format.dart';
|
|
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
|
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
|
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
|
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
|
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
|
|
|
class SyncSettingsScreen extends HookConsumerWidget {
|
|
const SyncSettingsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final syncInfo = ref.watch(
|
|
syncRepositoryProvider.select((value) => value.value?.account),
|
|
);
|
|
|
|
final generalSettings = ref.watch(generalSettingsWithDefaultsProvider);
|
|
|
|
final syncStarted = ref.watch(
|
|
syncEventProvider.select(
|
|
(value) => value.isLoading || value.value?.$1 == SyncEvent.started,
|
|
),
|
|
);
|
|
final isSyncing = syncStarted || syncInfo?.syncing == true;
|
|
|
|
final syncText = useMemoized(() {
|
|
if (isSyncing) {
|
|
return 'Synchronization in progress';
|
|
}
|
|
|
|
final timestamp = syncInfo?.lastSyncedAt;
|
|
if (timestamp == null || timestamp <= 0) {
|
|
return 'Never synced';
|
|
}
|
|
|
|
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
|
final formattedDate = ref
|
|
.read(formatProvider.notifier)
|
|
.fullDateTime(date.toLocal());
|
|
|
|
return 'Last synced: $formattedDate';
|
|
}, [syncInfo, isSyncing]);
|
|
|
|
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
|
|
|
final syncController = useAnimationController(
|
|
duration: disableAnimations ? Duration.zero : const Duration(seconds: 2),
|
|
);
|
|
final search = useSettingsSearch();
|
|
|
|
useEffect(() {
|
|
if (isSyncing && !disableAnimations) {
|
|
unawaited(syncController.repeat());
|
|
} else {
|
|
syncController.stop();
|
|
syncController.reset();
|
|
}
|
|
return null;
|
|
}, [isSyncing, disableAnimations]);
|
|
|
|
final sections = <SettingsSectionDefinition>[
|
|
SettingsSectionDefinition(
|
|
title: 'Account',
|
|
entries: [
|
|
SettingsEntryDefinition(
|
|
title: syncInfo?.authenticated == true
|
|
? 'Signed in account'
|
|
: 'Sign in',
|
|
subtitle: 'Account status, QR pairing, and device name',
|
|
keywords: const ['pairing', 'device name'],
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.account_circle_outlined),
|
|
title: Text(syncInfo?.email ?? 'Not signed in'),
|
|
subtitle: Text(
|
|
syncInfo?.needsReauth == true
|
|
? 'Authentication expired. Sign in again to continue syncing.'
|
|
: syncInfo?.authenticated == true
|
|
? (syncInfo?.displayName ?? 'Signed in')
|
|
: 'Sign in to synchronize tabs, bookmarks, and history',
|
|
),
|
|
trailing: syncInfo?.authenticated == true
|
|
? IconButton(
|
|
icon: const Icon(Icons.logout),
|
|
tooltip: 'Sign Out',
|
|
onPressed: isSyncing
|
|
? null
|
|
: () async {
|
|
final confirmed =
|
|
await _showSignOutConfirmation(context);
|
|
if (confirmed == true) {
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.signOut();
|
|
}
|
|
},
|
|
)
|
|
: const Icon(Icons.login),
|
|
onTap: (syncInfo?.authenticated == true || isSyncing)
|
|
? null
|
|
: () async {
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.signIn();
|
|
},
|
|
),
|
|
if (syncInfo?.authenticated != true && !isSyncing) ...[
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.qr_code_scanner),
|
|
title: const Text('Scan QR Code to pair'),
|
|
subtitle: const Text(
|
|
'Scan a QR code from firefox.com/pair on desktop',
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () async {
|
|
final barcode = await showQrScannerDialog(context);
|
|
|
|
final code = barcode?.code;
|
|
if (code == null || code.isEmpty) return;
|
|
|
|
final uri = Uri.tryParse(code);
|
|
if (uri == null || !uri.hasScheme) {
|
|
if (context.mounted) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
'Invalid QR code: not a valid URL',
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.signInWithPairing(code);
|
|
},
|
|
),
|
|
],
|
|
if (syncInfo?.authenticated == true) ...[
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.devices),
|
|
title: const Text('Device Name'),
|
|
subtitle: ref
|
|
.watch(syncDeviceNameProvider)
|
|
.when(
|
|
data: (name) => Text(name ?? 'Unknown'),
|
|
loading: () => const Text('Loading...'),
|
|
error: (_, _) => const Text('Unknown'),
|
|
),
|
|
trailing: const Icon(Icons.edit_outlined),
|
|
onTap: isSyncing
|
|
? null
|
|
: () async {
|
|
final currentName = ref
|
|
.read(syncRepositoryProvider)
|
|
.value
|
|
?.deviceName;
|
|
|
|
if (currentName != null && context.mounted) {
|
|
await _showDeviceNameDialog(
|
|
context,
|
|
currentName: currentName,
|
|
onSave: (newName) {
|
|
return ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.setDeviceName(newName);
|
|
},
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SettingsSectionDefinition(
|
|
title: 'Synchronization',
|
|
entries: [
|
|
SettingsEntryDefinition(
|
|
title: 'Sync Now',
|
|
subtitle: syncText,
|
|
keywords: const ['history', 'bookmarks', 'tabs'],
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
leading: RotationTransition(
|
|
turns: Tween<double>(
|
|
begin: 0,
|
|
end: -1,
|
|
).animate(syncController),
|
|
child: const Icon(Icons.sync),
|
|
),
|
|
title: const Text('Sync Now'),
|
|
subtitle: Text(syncText),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: isSyncing
|
|
? null
|
|
: () async {
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.syncNow();
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile.adaptive(
|
|
title: const Text('Sync History'),
|
|
value: _engineEnabled(syncInfo, SyncEngineValue.history),
|
|
onChanged: (syncInfo == null || isSyncing)
|
|
? null
|
|
: (value) async {
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.setEngineEnabled(SyncEngineValue.history, value);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile.adaptive(
|
|
title: const Text('Sync Bookmarks'),
|
|
value: _engineEnabled(syncInfo, SyncEngineValue.bookmarks),
|
|
onChanged: (syncInfo == null || isSyncing)
|
|
? null
|
|
: (value) async {
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.setEngineEnabled(
|
|
SyncEngineValue.bookmarks,
|
|
value,
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile.adaptive(
|
|
title: const Text('Sync Open Tabs'),
|
|
value: _engineEnabled(syncInfo, SyncEngineValue.tabs),
|
|
onChanged: (syncInfo == null || isSyncing)
|
|
? null
|
|
: (value) async {
|
|
await ref
|
|
.read(syncRepositoryProvider.notifier)
|
|
.setEngineEnabled(SyncEngineValue.tabs, value);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SettingsSectionDefinition(
|
|
title: 'Server Overrides',
|
|
entries: [
|
|
SettingsEntryDefinition(
|
|
title: 'Server overrides',
|
|
subtitle: 'Custom Firefox Account and token server endpoints',
|
|
keywords: const ['fxa', 'token server'],
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
title: const Text('FxA Server Override'),
|
|
subtitle: Text(
|
|
generalSettings.syncServerOverride.isEmpty
|
|
? 'Default Mozilla server'
|
|
: generalSettings.syncServerOverride,
|
|
),
|
|
trailing: const Icon(Icons.edit_outlined),
|
|
onTap: isSyncing
|
|
? null
|
|
: () => _showTextSettingDialog(
|
|
context,
|
|
title: 'FxA Server Override',
|
|
initialValue: generalSettings.syncServerOverride,
|
|
hint: 'https://accounts.firefox.com',
|
|
onSave: (value) {
|
|
return ref
|
|
.read(
|
|
saveGeneralSettingsControllerProvider
|
|
.notifier,
|
|
)
|
|
.save(
|
|
(current) => current.copyWith
|
|
.syncServerOverride(value.trim()),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
title: const Text('Sync Token Server Override'),
|
|
subtitle: Text(
|
|
generalSettings.syncTokenServerOverride.isEmpty
|
|
? 'Automatic from FxA server'
|
|
: generalSettings.syncTokenServerOverride,
|
|
),
|
|
trailing: const Icon(Icons.edit_outlined),
|
|
onTap: isSyncing
|
|
? null
|
|
: () => _showTextSettingDialog(
|
|
context,
|
|
title: 'Sync Token Server Override',
|
|
initialValue: generalSettings.syncTokenServerOverride,
|
|
hint:
|
|
'https://token.services.mozilla.com/1.0/sync/1.5',
|
|
onSave: (value) {
|
|
return ref
|
|
.read(
|
|
saveGeneralSettingsControllerProvider
|
|
.notifier,
|
|
)
|
|
.save(
|
|
(current) => current.copyWith
|
|
.syncTokenServerOverride(value.trim()),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
const ListTile(
|
|
dense: true,
|
|
title: Text(
|
|
'Restart the app after changing server overrides.',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
];
|
|
|
|
final filteredSections = filterSettingsSections(
|
|
sections: sections,
|
|
query: search.rawQuery,
|
|
);
|
|
|
|
return SettingsCustomScrollScaffold(
|
|
title: 'Firefox Sync',
|
|
searchController: search.controller,
|
|
searchHintText: 'Search sync settings',
|
|
slivers: [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
|
|
sliver: SliverToBoxAdapter(
|
|
child: SettingsSectionList(
|
|
sections: filteredSections,
|
|
query: search.rawQuery,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
static bool _engineEnabled(SyncAccountInfo? info, SyncEngineValue engine) {
|
|
final engines = info?.engines;
|
|
if (engines == null) {
|
|
return true;
|
|
}
|
|
|
|
for (final status in engines) {
|
|
if (status.engine == engine) {
|
|
return status.enabled;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
static Future<bool?> _showSignOutConfirmation(BuildContext context) {
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: const Text('Sign out?'),
|
|
content: const Text(
|
|
'Are you sure you want to sign out of Firefox Sync?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Sign Out'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
static Future<void> _showDeviceNameDialog(
|
|
BuildContext context, {
|
|
required String currentName,
|
|
required Future<bool> Function(String name) onSave,
|
|
}) async {
|
|
final controller = TextEditingController(text: currentName);
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: const Text('Device Name'),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(hintText: 'Enter device name'),
|
|
autofocus: true,
|
|
inputFormatters: [LengthLimitingTextInputFormatter(128)],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
final newName = controller.text.trim();
|
|
if (newName.isEmpty) {
|
|
if (context.mounted) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
'Device name cannot be empty',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final success = await onSave(newName).catchError((
|
|
Object error,
|
|
StackTrace stackTrace,
|
|
) {
|
|
logger.e(
|
|
'Failed to update device name',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return false;
|
|
});
|
|
|
|
if (!success) {
|
|
if (context.mounted) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
'Failed to update device name',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
static Future<void> _showTextSettingDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String initialValue,
|
|
required String hint,
|
|
required Future<void> Function(String value) onSave,
|
|
}) async {
|
|
final controller = TextEditingController(text: initialValue);
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: Text(title),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(hintText: hint),
|
|
autofocus: true,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
final value = controller.text.trim();
|
|
if (value.isNotEmpty) {
|
|
final uri = Uri.tryParse(value);
|
|
if (uri == null || uri.scheme != 'https') {
|
|
if (context.mounted) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
'Must be a valid HTTPS URL',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
await onSave(controller.text);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|