Files
MrbWebLibre/apps/weblibre/lib/features/sync/presentation/screens/sync_settings.dart
T

551 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:qr_code_scanner_plus/qr_code_scanner_plus.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 showDialog<Barcode>(
context: context,
builder: (_) => const QrScannerDialog(),
);
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'),
),
],
);
},
);
}
}