Files
MrbWebLibre/app/lib/features/sync/presentation/screens/sync_settings.dart
T
2026-02-21 08:29:05 +01:00

464 lines
17 KiB
Dart

import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart';
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/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/sections.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 syncController = useAnimationController(
duration: const Duration(seconds: 2),
);
useEffect(() {
if (isSyncing) {
unawaited(syncController.repeat());
} else {
syncController.stop();
syncController.reset();
}
return null;
}, [isSyncing]);
return Scaffold(
appBar: AppBar(title: const Text('Firefox Sync')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: [
const SettingSection(name: 'Account'),
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)
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)
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);
},
);
}
},
),
const SettingSection(name: 'Synchronization'),
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();
},
),
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);
},
),
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,
);
},
),
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);
},
),
const SettingSection(name: 'Server Overrides'),
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()),
);
},
),
),
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 ListTile(
dense: true,
title: Text(
'Restart the app after changing server overrides.',
style: TextStyle(fontSize: 12),
),
),
],
);
},
),
),
);
}
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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Device name cannot be empty'),
),
);
}
return;
}
final success = await onSave(newName).catchError((_) => false);
if (!success) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Must be a valid HTTPS URL'),
),
);
}
return;
}
}
await onSave(controller.text);
if (context.mounted) {
Navigator.of(context).pop();
}
},
child: const Text('Save'),
),
],
);
},
);
}
}