464 lines
17 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|