sync initial

This commit is contained in:
Fabian Freund
2026-02-21 08:29:05 +01:00
parent 03f9dfe9f5
commit 5bdc3c6027
62 changed files with 5724 additions and 519 deletions
@@ -179,6 +179,19 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthCustomTabActivity"
android:exported="false"
android:taskAffinity=""
android:autoRemoveFromRecents="false"
android:theme="@style/ExternalAppBrowserTheme"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
android:exported="false" />
<activity
android:theme="@style/AddonsActivityTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
@@ -20,9 +20,26 @@
package eu.weblibre.gecko
import android.app.Application
import android.content.SharedPreferences
import eu.weblibre.flutter_mozilla_components.ActiveProfile
import eu.weblibre.flutter_mozilla_components.MegazordSetup
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
MegazordSetup.setupEarlyMainProcess()
// Resolve active profile EARLY so cold-start WorkManager workers
// get profile-prefixed SharedPreferences
ActiveProfile.resolveFromDisk(this)
}
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
val pfx = ActiveProfile.prefix
if (pfx != null && name in ActiveProfile.FXA_SHARED_PREFERENCE_NAMES) {
return super.getSharedPreferences("${pfx}_$name", mode)
}
return super.getSharedPreferences(name, mode)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ part 'format.g.dart';
@Riverpod(keepAlive: true)
class Format extends _$Format {
String fullDateTimeWithTimezone(DateTime date) {
String fullDateTime(DateTime date) {
final pattern = DateFormat('yMMMMd').addPattern('Hm');
return pattern.format(date);
+1 -1
View File
@@ -32,7 +32,7 @@ final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
Format create() => Format();
}
String _$formatHash() => r'fe7fcdd19b512784a36f3838c57701030475e429';
String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff';
abstract class _$Format extends $AsyncNotifier<void> {
FutureOr<void> build();
+1
View File
@@ -70,6 +70,7 @@ import 'package:weblibre/features/settings/presentation/screens/tabs_behavior_se
import 'package:weblibre/features/settings/presentation/screens/tracking_protection_exceptions.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart';
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart';
+26
View File
@@ -1427,6 +1427,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
name: 'ErrorLogsRoute',
factory: $ErrorLogsRoute._fromState,
),
GoRouteData.$route(
path: 'sync',
name: 'SyncSettingsRoute',
factory: $SyncSettingsRoute._fromState,
),
],
);
@@ -1791,6 +1796,27 @@ mixin $ErrorLogsRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location);
}
mixin $SyncSettingsRoute on GoRouteData {
static SyncSettingsRoute _fromState(GoRouterState state) =>
SyncSettingsRoute();
@override
String get location => GoRouteData.$location('/settings/sync');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $torProxyRoute => GoRouteData.$route(
path: '/tor',
name: 'TorProxyRoute',
@@ -80,6 +80,7 @@ part of 'routes.dart';
path: 'custom_tracking_protection',
),
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'),
],
)
class SettingsRoute extends GoRouteData with $SettingsRoute {
@@ -214,3 +215,10 @@ class CustomTrackingProtectionRoute extends GoRouteData
return const CustomTrackingProtectionScreen();
}
}
class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const SyncSettingsScreen();
}
}
@@ -39,8 +39,8 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
@@ -55,6 +55,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.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/move_to_background.dart';
@@ -303,6 +304,44 @@ class BrowserScreen extends HookConsumerWidget {
},
);
useOnAppLifecycleStateChange((previous, current) {
switch (current) {
case AppLifecycleState.resumed:
if (current != AppLifecycleState.resumed) {
return;
}
if (!ref.read(syncIsAuthenticatedProvider)) {
return;
}
unawaited(() async {
try {
final openedTabs = await ref
.read(syncRepositoryProvider.notifier)
.pollIncomingTabsAndOpen();
if (openedTabs > 0 && context.mounted) {
ui_helper.showOpenedTabsFromAnotherDeviceMessage(
context,
openedTabs,
);
}
} catch (e, s) {
logger.e(
'Failed polling incoming sync tabs on resume',
error: e,
stackTrace: s,
);
}
}());
case AppLifecycleState.detached:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
}
});
final pointerMoveEventsController = useStreamController<Offset>();
// Watch sheet state for rendering in Stack
@@ -317,8 +356,7 @@ class BrowserScreen extends HookConsumerWidget {
// Toolbar is visible when: sheet is shown OR (not fullscreen AND controller says visible)
// The controller handles loading-start show internally via ref.listen on isLoading
final effectiveAppBarVisible =
toolbarState == ToolbarVisibility.visible;
final effectiveAppBarVisible = toolbarState == ToolbarVisibility.visible;
final topToolbarVisible =
sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible);
final bottomToolbarVisible =
@@ -1020,6 +1058,16 @@ class _ViewTabsSheet extends HookConsumerWidget {
final tabsViewMode = ref.watch(tabsViewModeControllerProvider);
final tabsReorderable = ref.watch(tabsReorderableControllerProvider);
final isSyncedScope = ref.watch(
effectiveTabsTrayScopeProvider.select(
(scope) => scope == TabsTrayScope.synced,
),
);
final effectiveTabsViewMode = isSyncedScope
? TabsViewMode.list
: tabsViewMode;
final draggableScrollableController = useDraggableScrollableController(
keys: [tabsReorderable],
);
@@ -1037,7 +1085,7 @@ class _ViewTabsSheet extends HookConsumerWidget {
topRight: Radius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: switch (tabsViewMode) {
child: switch (effectiveTabsViewMode) {
TabsViewMode.list => ViewTabListWidget(
scrollController: scrollController,
showNewTabFab: true,
@@ -26,6 +26,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/scroll_visibility.dart';
@@ -37,6 +38,16 @@ class TabViewScreen extends HookConsumerWidget {
final tabsViewMode = ref.watch(tabsViewModeControllerProvider);
final tabsReorderable = ref.watch(tabsReorderableControllerProvider);
final isSyncedScope = ref.watch(
effectiveTabsTrayScopeProvider.select(
(scope) => scope == TabsTrayScope.synced,
),
);
final effectiveTabsViewMode = isSyncedScope
? TabsViewMode.list
: tabsViewMode;
final scrollController = useScrollController(keys: [tabsReorderable]);
// Track FAB visibility based on scroll direction
@@ -45,7 +56,7 @@ class TabViewScreen extends HookConsumerWidget {
return Dialog.fullscreen(
child: Scaffold(
body: SafeArea(
child: switch (tabsViewMode) {
child: switch (effectiveTabsViewMode) {
TabsViewMode.list => ViewTabListWidget(
key: ValueKey(tabsReorderable),
scrollController: scrollController,
@@ -73,31 +84,35 @@ class TabViewScreen extends HookConsumerWidget {
),
},
),
floatingActionButton: AnimatedSlide(
duration: const Duration(milliseconds: 200),
offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isFabVisible.value ? 1.0 : 0.0,
child: FloatingActionButton(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
floatingActionButton: isSyncedScope
? null
: AnimatedSlide(
duration: const Duration(milliseconds: 200),
offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isFabVisible.value ? 1.0 : 0.0,
child: FloatingActionButton(
onPressed: () async {
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
child: const Icon(Icons.add),
),
),
),
if (context.mounted) {
const BrowserRoute().go(context);
}
},
child: const Icon(Icons.add),
),
),
),
),
);
}
@@ -670,6 +670,7 @@ class ShareMenuButton extends HookConsumerWidget {
OpenInAppMenuItemButton(selectedTabId: selectedTabId),
ShareScreenshotMenuItemButton(selectedTabId: selectedTabId),
ShareMenuItemButton(selectedTabId: selectedTabId),
SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId),
ShowQrCodeMenuItemButton(selectedTabId: selectedTabId),
],
builder: (context, controller, child) {
@@ -18,6 +18,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -31,12 +33,15 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.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/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
import 'package:weblibre/utils/exit_app.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
/// Navigation drawer for the browser screen.
/// Contains all navigation destinations and settings.
@@ -52,7 +57,7 @@ class BrowserNavigationDrawer extends HookConsumerWidget {
children: [
// Profile Header
_ProfileHeader(),
_SyncTile(),
const Divider(),
// Section 1: Tools & Configuration
@@ -327,3 +332,76 @@ class _ExtensionsSection extends HookConsumerWidget {
);
}
}
/// Sync tile widget shown in navigation drawer when sync is active.
/// Displays sync status and allows manual sync trigger.
class _SyncTile extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
// Only show if sync is active
if (!isAuthenticated) {
return const SizedBox.shrink();
}
final syncInfo = ref.watch(
syncRepositoryProvider.select((value) => value.value?.account),
);
final syncStarted = ref.watch(
syncEventProvider.select(
(value) => value.isLoading || value.value?.$1 == SyncEvent.started,
),
);
final isSyncing = syncStarted || syncInfo?.syncing == true;
final controller = useAnimationController(
duration: const Duration(seconds: 2),
);
useEffect(() {
if (isSyncing) {
unawaited(controller.repeat());
} else {
controller.stop();
controller.reset();
}
return null;
}, [isSyncing]);
return ListTile(
leading: RotationTransition(
turns: Tween<double>(begin: 0, end: -1).animate(controller),
child: const Icon(Icons.sync),
),
title: const Text('Sync Now'),
onTap: () async {
await ref.read(syncRepositoryProvider.notifier).syncNow();
final openedTabs = await ref
.read(syncRepositoryProvider.notifier)
.pollIncomingTabsAndOpen();
if (context.mounted) {
if (openedTabs > 0) {
ui_helper.showOpenedTabsFromAnotherDeviceMessage(
context,
openedTabs,
);
} else {
ui_helper.showInfoMessage(
context,
'Synchronization complete',
duration: const Duration(seconds: 2),
);
}
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
);
}
}
@@ -26,13 +26,16 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:share_plus/share_plus.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/content_selection_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class ShareMenuItemButton extends HookConsumerWidget {
const ShareMenuItemButton({super.key, required this.selectedTabId});
@@ -287,3 +290,97 @@ class CopyAddressMenuItemButton extends HookConsumerWidget {
);
}
}
class SendTabToDeviceMenuItemButton extends HookConsumerWidget {
final String? selectedTabId;
const SendTabToDeviceMenuItemButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (selectedTabId == null) {
return const SizedBox.shrink();
}
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
final devices = ref.watch(syncDevicesProvider);
if (!isAuthenticated) {
return const SizedBox.shrink();
}
return Skeletonizer(
enabled: devices.isLoading && devices.value == null,
child: SubmenuButton(
leadingIcon: const Icon(Icons.send_outlined),
menuChildren: devices.when(
data: (deviceList) {
final targets = deviceList
.where((device) => !device.isCurrentDevice && device.canSendTab)
.toList(growable: false);
if (targets.isEmpty) {
return const [MenuItemButton(child: Text('No target devices'))];
}
return targets
.map((device) {
return MenuItemButton(
closeOnActivate: false,
leadingIcon: const Icon(Icons.devices_other),
child: Text(device.displayName),
onPressed: () async {
final tabState = ref.read(
tabStateProvider(selectedTabId),
);
if (tabState == null) {
return;
}
final title = tabState.title.isNotEmpty
? tabState.title
: tabState.url.toString();
final success = await ref
.read(syncRepositoryProvider.notifier)
.sendTabToDevice(
deviceId: device.deviceId,
title: title,
url: tabState.url.toString(),
);
if (context.mounted) {
if (success) {
ui_helper.showInfoMessage(
context,
'Sent tab to ${device.displayName}',
);
} else {
ui_helper.showErrorMessage(
context,
'Failed to send tab',
);
}
MenuController.maybeOf(context)?.close();
}
},
);
})
.toList(growable: false);
},
loading: () => const [
MenuItemButton(
leadingIcon: Icon(Icons.devices_other),
child: Text('Loading devices...'),
),
],
error: (_, _) => const [
MenuItemButton(child: Text('Failed to load devices')),
],
),
child: const Text('Send To Device'),
),
);
}
}
@@ -475,6 +475,7 @@ class TabMenu extends HookConsumerWidget {
OpenInAppMenuItemButton(selectedTabId: selectedTabId),
ShareScreenshotMenuItemButton(selectedTabId: selectedTabId),
ShareMenuItemButton(selectedTabId: selectedTabId),
SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId),
ShowQrCodeMenuItemButton(selectedTabId: selectedTabId),
],
leadingIcon: const Icon(Icons.share),
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class _TabDraggable extends HookConsumerWidget {
@@ -149,8 +150,58 @@ class _TabListView extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// final screenWidth = MediaQuery.of(context).size.width;
final scope = ref.watch(effectiveTabsTrayScopeProvider);
return switch (scope) {
TabsTrayScope.synced => _buildSyncedTabsView(context, ref),
_ => _buildLocalTabsView(context, ref),
};
}
Widget _buildSyncedTabsView(BuildContext context, WidgetRef ref) {
final syncedTabs = ref.watch(syncedTabsForSelectedDeviceProvider);
return syncedTabs.when(
skipLoadingOnReload: true,
data: (tabs) {
if (tabs.isEmpty) {
return const Center(child: Text('No synced tabs available'));
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ListView.builder(
controller: scrollController,
itemCount: tabs.length,
itemBuilder: (context, index) {
final tab = tabs[index].tab;
final uri = Uri.tryParse(tab.url);
if (uri == null) {
return const SizedBox.shrink();
}
return SyncedListTabPreview(
title: tab.title.isNotEmpty ? tab.title : tab.url,
url: uri,
deviceName: tabs[index].deviceName,
onTap: () async {
await OpenSharedContentRoute(
sharedUrl: uri.toString(),
).push(context);
},
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load synced tabs: $error')),
);
}
Widget _buildLocalTabsView(BuildContext context, WidgetRef ref) {
final containerId = ref.watch(selectedContainerProvider);
final filteredTabEntities = ref.watch(
@@ -417,6 +468,12 @@ class ViewTabListWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSyncedScope = ref.watch(
effectiveTabsTrayScopeProvider.select(
(scope) => scope == TabsTrayScope.synced,
),
);
final isFabVisible = useState(true);
final lastSheetSize = useRef(0.0);
final isInitialized = useRef(false);
@@ -516,7 +573,7 @@ class ViewTabListWidget extends HookConsumerWidget {
onClose: onClose,
),
),
if (showNewTabFab)
if (showNewTabFab && !isSyncedScope)
AnimatedSlide(
duration: const Duration(milliseconds: 200),
offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
@@ -36,6 +36,7 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class GridTabItemContainer extends StatelessWidget {
@@ -378,6 +379,51 @@ class ListTabPreview extends HookConsumerWidget {
}
}
class SyncedListTabPreview extends StatelessWidget {
const SyncedListTabPreview({
super.key,
required this.title,
required this.url,
required this.deviceName,
required this.onTap,
});
final String title;
final Uri url;
final String deviceName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.only(left: 8, right: 6),
leading: UrlIcon([url], iconSize: 20),
title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
deviceName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
UriBreadcrumb(uri: url),
],
),
trailing: const Icon(Icons.open_in_new),
);
}
}
class SingleGridTabPreview extends HookConsumerWidget {
final String tabId;
final String? activeTabId;
@@ -43,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
@@ -50,6 +51,151 @@ import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
/// Widget for tab filters (container chips with synced option)
class _TabFilters extends ConsumerWidget {
final TabsViewMode tabsViewMode;
const _TabFilters({required this.tabsViewMode});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSyncedScope = ref.watch(
effectiveTabsTrayScopeProvider.select(
(scope) => scope == TabsTrayScope.synced,
),
);
final selectedContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.value),
);
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
final syncedTabCountAsync = ref.watch(syncedTabsTotalCountProvider);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ContainerChips(
showGroupSuggestions: switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
},
enableDragAndDrop: switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
},
showSyncedChip: isAuthenticated && tabsViewMode != TabsViewMode.tree,
syncedChipSelected: isSyncedScope,
syncedTabCount: syncedTabCountAsync.when(
data: (count) => count,
loading: () => 0,
error: (_, _) => 0,
),
onSyncedChipSelected: () {
ref.read(tabsTrayScopeControllerProvider.notifier).showSynced();
},
selectedContainer: selectedContainer,
onSelected: (container) async {
ref.read(tabsTrayScopeControllerProvider.notifier).showLocal();
if (container != null) {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (context.mounted &&
result == SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (!context.mounted || !shouldStartProxy) return;
final dialogResult = await showDialog<bool>(
context: context,
builder: (context) {
return const TorDialog();
},
);
if (dialogResult == true) {
await ref
.read(startProxyControllerProvider.notifier)
.startProxy();
}
}
} else {
ref.read(selectedContainerProvider.notifier).clearContainer();
}
},
onDeleted: (container) {
ref.read(tabsTrayScopeControllerProvider.notifier).showLocal();
ref.read(selectedContainerProvider.notifier).clearContainer();
},
onLongPress: (container) async {
await ContainerEditRoute(
containerData: jsonEncode(container.toJson()),
).push(context);
},
),
if (isSyncedScope) ...[
const SizedBox(height: 8),
_SyncedDeviceSelector(),
],
],
);
}
}
/// Widget for synced device selector
class _SyncedDeviceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final remoteDevicesAsync = ref.watch(syncRemoteTabsProvider);
final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider);
return remoteDevicesAsync.when(
skipLoadingOnReload: true,
data: (devices) {
if (devices.isEmpty) {
return const SizedBox.shrink();
}
final effectiveSelectedDeviceId =
selectedDeviceId != null &&
devices.any((device) => device.deviceId == selectedDeviceId)
? selectedDeviceId
: devices.first.deviceId;
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: devices
.map((device) {
return Padding(
padding: const EdgeInsets.only(right: 6.0),
child: ChoiceChip(
label: Text(device.deviceName),
selected: effectiveSelectedDeviceId == device.deviceId,
onSelected: (_) {
ref
.read(selectedSyncedTabsDeviceIdProvider.notifier)
.selectDevice(device.deviceId);
},
),
);
})
.toList(growable: false),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
);
}
}
class TabViewHeader extends HookConsumerWidget {
static const headerSize = 124.0;
@@ -82,6 +228,13 @@ class TabViewHeader extends HookConsumerWidget {
),
);
final selectedContainerId = ref.watch(selectedContainerProvider);
final isSyncedScope = ref.watch(
effectiveTabsTrayScopeProvider.select(
(scope) => scope == TabsTrayScope.synced,
),
);
useOnListenableChange(searchTextController, () async {
if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) {
await ref
@@ -153,13 +306,15 @@ class TabViewHeader extends HookConsumerWidget {
.toList(),
child: IconButton(
tooltip: 'Change view mode',
onPressed: () {
if (viewModeMenuController.isOpen) {
viewModeMenuController.close();
} else {
viewModeMenuController.open();
}
},
onPressed: isSyncedScope
? null
: () {
if (viewModeMenuController.isOpen) {
viewModeMenuController.close();
} else {
viewModeMenuController.open();
}
},
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -278,122 +433,133 @@ class TabViewHeader extends HookConsumerWidget {
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.closeCircle),
onPressed: isSyncedScope
? null
: () async {
final result = await showCloseAllTabsDialog(
context,
);
if (result == true) {
final count = await ref
.read(
tabDataRepositoryProvider.notifier,
)
.closeContainerTabs(
selectedContainerId,
);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(
tabRepositoryProvider.notifier,
)
.undoClose,
count: count.length,
);
}
}
},
child: const Text('Close All Tabs'),
onPressed: () async {
final result = await showCloseAllTabsDialog(
context,
);
if (result == true) {
final container = ref.read(
selectedContainerProvider,
);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeContainerTabs(container);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(tabRepositoryProvider.notifier)
.undoClose,
count: count.length,
);
}
}
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.incognitoCircleOff),
onPressed: isSyncedScope
? null
: () async {
final result =
await showCloseAllPrivateTabsDialog(
context,
);
if (result == true) {
final count = await ref
.read(
tabDataRepositoryProvider.notifier,
)
.closeContainerTabs(
selectedContainerId,
includeRegular: false,
);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(
tabRepositoryProvider.notifier,
)
.undoClose,
count: count.length,
);
}
}
},
child: const Text('Close Private Tabs'),
onPressed: () async {
final result = await showCloseAllPrivateTabsDialog(
context,
);
if (result == true) {
final container = ref.read(
selectedContainerProvider,
);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeContainerTabs(
container,
includeRegular: false,
);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(tabRepositoryProvider.notifier)
.undoClose,
count: count.length,
);
}
}
},
),
const Divider(),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline),
child: const Text('Bookmark all'),
onPressed: () async {
final choice = await showBookmarkAllDialog(context);
if (choice == null || !context.mounted) return;
final containerId = ref.read(
selectedContainerProvider,
);
final tabData = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabsData(containerId);
if (choice == BookmarkAllChoice.fast) {
if (!context.mounted) return;
final folderGuid = await showSelectFolderDialog(
context,
);
if (folderGuid == null) return;
final repo = ref.read(
bookmarksRepositoryProvider.notifier,
);
for (final tab in tabData) {
if (tab.url != null) {
await repo.addBookmark(
parentGuid: folderGuid,
url: tab.url!,
title: tab.title ?? tab.url.toString(),
onPressed: isSyncedScope
? null
: () async {
final choice = await showBookmarkAllDialog(
context,
);
}
}
if (choice == null || !context.mounted) {
return;
}
if (context.mounted) {
ui_helper.showInfoMessage(
context,
'${tabData.length} bookmark(s) added',
);
}
} else {
for (final tab in tabData) {
if (context.mounted) {
await BookmarkEntryAddRoute(
bookmarkInfo: jsonEncode(
BookmarkInfo(
title: tab.title,
url: tab.url.toString(),
).encode(),
),
).push(context);
}
}
}
},
final tabData = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabsData(
selectedContainerId,
);
if (choice == BookmarkAllChoice.fast) {
if (!context.mounted) return;
final folderGuid =
await showSelectFolderDialog(context);
if (folderGuid == null) return;
final repo = ref.read(
bookmarksRepositoryProvider.notifier,
);
for (final tab in tabData) {
if (tab.url != null) {
await repo.addBookmark(
parentGuid: folderGuid,
url: tab.url!,
title:
tab.title ?? tab.url.toString(),
);
}
}
if (context.mounted) {
ui_helper.showInfoMessage(
context,
'${tabData.length} bookmark(s) added',
);
}
} else {
for (final tab in tabData) {
if (context.mounted) {
await BookmarkEntryAddRoute(
bookmarkInfo: jsonEncode(
BookmarkInfo(
title: tab.title,
url: tab.url.toString(),
).encode(),
),
).push(context);
}
}
}
},
child: const Text('Bookmark all'),
),
Consumer(
builder: (context, ref, child) {
@@ -582,66 +748,7 @@ class TabViewHeader extends HookConsumerWidget {
),
Consumer(
builder: (context, ref, child) {
final selectedContainer = ref.watch(
selectedContainerDataProvider.select(
(value) => value.value,
),
);
return ContainerChips(
showGroupSuggestions: switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
},
enableDragAndDrop: switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
},
selectedContainer: selectedContainer,
onSelected: (container) async {
if (container != null) {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (context.mounted &&
result == SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (!context.mounted || !shouldStartProxy) return;
final dialogResult = await showDialog<bool>(
context: context,
builder: (context) {
return const TorDialog();
},
);
if (dialogResult == true) {
await ref
.read(startProxyControllerProvider.notifier)
.startProxy();
}
}
} else {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
}
},
onDeleted: (container) {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
},
onLongPress: (container) async {
await ContainerEditRoute(
containerData: jsonEncode(container.toJson()),
).push(context);
},
);
return _TabFilters(tabsViewMode: tabsViewMode);
},
),
const SizedBox(height: 8),
@@ -178,7 +178,7 @@ class FeedSearch extends HookConsumerWidget {
child: Text(
ref
.read(formatProvider.notifier)
.fullDateTimeWithTimezone(articleDate),
.fullDateTime(articleDate),
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
@@ -186,7 +186,7 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
);
}
SingleSelectable<String> siteAssignedContainerId(Uri uri) {
Selectable<String> siteAssignedContainerId(Uri uri) {
return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin);
}
@@ -174,7 +174,8 @@ class ContainerRepository extends _$ContainerRepository {
.read(tabDatabaseProvider)
.containerDao
.siteAssignedContainerId(uri)
.getSingle();
.get()
.then((value) => value.firstOrNull);
}
Future<List<String>> getContainersToClearOnExit() async {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'62c2b06970db4385ea0d2edc68bcf076c291a9b6';
r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -39,12 +39,12 @@ import 'package:weblibre/presentation/widgets/selectable_chips.dart';
class _UnassignedContainerChip extends ConsumerWidget {
final int? Function()? containerBadgeCount;
final ContainerData? selectedContainer;
final bool selected;
final void Function(ContainerDataWithCount?)? onSelected;
const _UnassignedContainerChip({
required this.containerBadgeCount,
required this.selectedContainer,
required this.selected,
required this.onSelected,
});
@@ -65,7 +65,7 @@ class _UnassignedContainerChip extends ConsumerWidget {
label: (tabCount > 0)
? Text(tabCount.toString())
: const SizedBox.shrink(),
selected: selectedContainer == null,
selected: selected,
showCheckmark: false,
onSelected: (value) {
if (value) {
@@ -76,6 +76,34 @@ class _UnassignedContainerChip extends ConsumerWidget {
}
}
class _SyncedTabsChip extends ConsumerWidget {
final bool selected;
final int count;
final VoidCallback onSelected;
const _SyncedTabsChip({
required this.selected,
required this.count,
required this.onSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return FilterChip(
avatar: const Icon(Icons.devices_other),
labelPadding: count > 0 ? null : const EdgeInsets.only(right: 2.0),
label: count > 0 ? Text(count.toString()) : const SizedBox.shrink(),
selected: selected,
showCheckmark: false,
onSelected: (value) {
if (value) {
onSelected();
}
},
);
}
}
class _ContainerSuggestionsChip extends ConsumerWidget {
const _ContainerSuggestionsChip();
@@ -135,6 +163,11 @@ class ContainerChips extends HookConsumerWidget {
final bool showUnassignedChip;
final bool showGroupSuggestions;
final bool enableDragAndDrop;
final bool showSyncedChip;
final bool syncedChipSelected;
final bool? unassignedChipSelected;
final int syncedTabCount;
final VoidCallback? onSyncedChipSelected;
final ContainerData? selectedContainer;
final bool Function(ContainerDataWithCount)? containerFilter;
@@ -157,6 +190,11 @@ class ContainerChips extends HookConsumerWidget {
this.showUnassignedChip = true,
this.showGroupSuggestions = false,
this.enableDragAndDrop = true,
this.showSyncedChip = false,
this.syncedChipSelected = false,
this.unassignedChipSelected,
this.syncedTabCount = 0,
this.onSyncedChipSelected,
});
@override
@@ -217,6 +255,12 @@ class ContainerChips extends HookConsumerWidget {
}
: null,
prefixListItems: [
if (showSyncedChip)
_SyncedTabsChip(
selected: syncedChipSelected,
count: syncedTabCount,
onSelected: onSyncedChipSelected ?? () {},
),
if (showUnassignedChip)
enableDragAndDrop
? TabDragContainerTarget(
@@ -224,14 +268,20 @@ class ContainerChips extends HookConsumerWidget {
child: _UnassignedContainerChip(
containerBadgeCount: () =>
containerBadgeCount?.call(null),
selectedContainer: selectedContainer,
selected:
unassignedChipSelected ??
(selectedContainer == null &&
!syncedChipSelected),
onSelected: onSelected,
),
)
: _UnassignedContainerChip(
containerBadgeCount: () =>
containerBadgeCount?.call(null),
selectedContainer: selectedContainer,
selected:
unassignedChipSelected ??
(selectedContainer == null &&
!syncedChipSelected),
onSelected: onSelected,
),
if (showGroupSuggestions)
@@ -41,6 +41,7 @@ class SettingsScreen extends HookConsumerWidget {
_PrivacySecurityTile(),
_SearchContentTile(),
_TabsBehaviorTile(),
_SyncTile(),
_FingerprintingTile(),
_AdvancedTile(),
],
@@ -176,6 +177,31 @@ class _FingerprintingTile extends StatelessWidget {
}
}
class _SyncTile extends StatelessWidget {
const _SyncTile();
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Firefox Sync'),
subtitle: const Text('Account, sync now, engine selection'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
leading: const Icon(Icons.sync),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await SyncSettingsRoute().push(context);
},
),
);
}
}
class _AdvancedTile extends StatelessWidget {
const _AdvancedTile();
@@ -0,0 +1,34 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
part 'sync_repository_state.g.dart';
enum SyncEvent {
started,
completed,
error;
}
@CopyWith()
class SyncRepositoryState with FastEquatable {
final SyncAccountInfo account;
final List<SyncDeviceTabs> remoteTabs;
final List<SyncDevice> devices;
final String? deviceName;
final SyncEvent? lastSyncEvent;
final String? lastSyncError;
SyncRepositoryState({
required this.account,
this.remoteTabs = const <SyncDeviceTabs>[],
this.devices = const <SyncDevice>[],
this.deviceName,
this.lastSyncEvent,
this.lastSyncError,
});
@override
List<Object?> get hashParameters =>
[account, remoteTabs, devices, deviceName, lastSyncEvent, lastSyncError];
}
@@ -0,0 +1,122 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_repository_state.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$SyncRepositoryStateCWProxy {
SyncRepositoryState account(SyncAccountInfo account);
SyncRepositoryState remoteTabs(List<SyncDeviceTabs> remoteTabs);
SyncRepositoryState devices(List<SyncDevice> devices);
SyncRepositoryState deviceName(String? deviceName);
SyncRepositoryState lastSyncEvent(SyncEvent? lastSyncEvent);
SyncRepositoryState lastSyncError(String? lastSyncError);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SyncRepositoryState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// SyncRepositoryState(...).copyWith(id: 12, name: "My name")
/// ```
SyncRepositoryState call({
SyncAccountInfo account,
List<SyncDeviceTabs> remoteTabs,
List<SyncDevice> devices,
String? deviceName,
SyncEvent? lastSyncEvent,
String? lastSyncError,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfSyncRepositoryState.copyWith(...)` or call `instanceOfSyncRepositoryState.copyWith.fieldName(value)` for a single field.
class _$SyncRepositoryStateCWProxyImpl implements _$SyncRepositoryStateCWProxy {
const _$SyncRepositoryStateCWProxyImpl(this._value);
final SyncRepositoryState _value;
@override
SyncRepositoryState account(SyncAccountInfo account) =>
call(account: account);
@override
SyncRepositoryState remoteTabs(List<SyncDeviceTabs> remoteTabs) =>
call(remoteTabs: remoteTabs);
@override
SyncRepositoryState devices(List<SyncDevice> devices) =>
call(devices: devices);
@override
SyncRepositoryState deviceName(String? deviceName) =>
call(deviceName: deviceName);
@override
SyncRepositoryState lastSyncEvent(SyncEvent? lastSyncEvent) =>
call(lastSyncEvent: lastSyncEvent);
@override
SyncRepositoryState lastSyncError(String? lastSyncError) =>
call(lastSyncError: lastSyncError);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SyncRepositoryState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// SyncRepositoryState(...).copyWith(id: 12, name: "My name")
/// ```
SyncRepositoryState call({
Object? account = const $CopyWithPlaceholder(),
Object? remoteTabs = const $CopyWithPlaceholder(),
Object? devices = const $CopyWithPlaceholder(),
Object? deviceName = const $CopyWithPlaceholder(),
Object? lastSyncEvent = const $CopyWithPlaceholder(),
Object? lastSyncError = const $CopyWithPlaceholder(),
}) {
return SyncRepositoryState(
account: account == const $CopyWithPlaceholder() || account == null
? _value.account
// ignore: cast_nullable_to_non_nullable
: account as SyncAccountInfo,
remoteTabs:
remoteTabs == const $CopyWithPlaceholder() || remoteTabs == null
? _value.remoteTabs
// ignore: cast_nullable_to_non_nullable
: remoteTabs as List<SyncDeviceTabs>,
devices: devices == const $CopyWithPlaceholder() || devices == null
? _value.devices
// ignore: cast_nullable_to_non_nullable
: devices as List<SyncDevice>,
deviceName: deviceName == const $CopyWithPlaceholder()
? _value.deviceName
// ignore: cast_nullable_to_non_nullable
: deviceName as String?,
lastSyncEvent: lastSyncEvent == const $CopyWithPlaceholder()
? _value.lastSyncEvent
// ignore: cast_nullable_to_non_nullable
: lastSyncEvent as SyncEvent?,
lastSyncError: lastSyncError == const $CopyWithPlaceholder()
? _value.lastSyncError
// ignore: cast_nullable_to_non_nullable
: lastSyncError as String?,
);
}
}
extension $SyncRepositoryStateCopyWith on SyncRepositoryState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfSyncRepositoryState.copyWith(...)` or `instanceOfSyncRepositoryState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$SyncRepositoryStateCWProxy get copyWith =>
_$SyncRepositoryStateCWProxyImpl(this);
}
@@ -0,0 +1,17 @@
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
class SyncedTabItem with FastEquatable {
final String deviceId;
final String deviceName;
final SyncRemoteTab tab;
SyncedTabItem({
required this.deviceId,
required this.deviceName,
required this.tab,
});
@override
List<Object?> get hashParameters => [deviceId, deviceName, tab];
}
@@ -0,0 +1,411 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/entities/synced_tab_item.dart';
part 'sync.g.dart';
@Riverpod(keepAlive: true)
bool syncIsAuthenticated(Ref ref) {
return ref.watch(
syncRepositoryProvider.select(
(value) => value.value?.account.authenticated == true,
),
);
}
enum TabsTrayScope { local, synced }
@Riverpod(keepAlive: true)
class TabsTrayScopeController extends _$TabsTrayScopeController {
@override
TabsTrayScope build() => TabsTrayScope.local;
void showLocal() {
state = TabsTrayScope.local;
}
void showSynced() {
state = TabsTrayScope.synced;
}
}
@Riverpod(keepAlive: true)
TabsTrayScope effectiveTabsTrayScope(Ref ref) {
final scope = ref.watch(tabsTrayScopeControllerProvider);
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
if (!isAuthenticated && scope == TabsTrayScope.synced) {
return TabsTrayScope.local;
}
return scope;
}
@Riverpod(keepAlive: true)
class SelectedSyncedTabsDeviceId extends _$SelectedSyncedTabsDeviceId {
@override
String? build() => null;
// ignore: use_setters_to_change_properties
void selectDevice(String? value) {
state = value;
}
}
@riverpod
Future<int> syncedTabsTotalCount(Ref ref) {
return ref.watch(
syncRemoteTabsProvider.selectAsync(
(devices) =>
devices.fold<int>(0, (count, device) => count + device.tabs.length),
),
);
}
@riverpod
Future<String?> effectiveSyncedTabsDeviceId(Ref ref) async {
final devices = await ref.watch(
syncRemoteTabsProvider.selectAsync((tabs) => tabs),
);
final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider);
if (devices.isEmpty) {
return null;
}
if (selectedDeviceId != null &&
devices.any((device) => device.deviceId == selectedDeviceId)) {
return selectedDeviceId;
}
return devices.first.deviceId;
}
@riverpod
Future<List<SyncedTabItem>> syncedTabsForSelectedDevice(Ref ref) async {
final devices = await ref.watch(
syncRemoteTabsProvider.selectAsync((tabs) => tabs),
);
final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider);
final effectiveSelectedDeviceId =
selectedDeviceId != null &&
devices.any((device) => device.deviceId == selectedDeviceId)
? selectedDeviceId
: devices.firstOrNull?.deviceId;
if (effectiveSelectedDeviceId == null) {
return const <SyncedTabItem>[];
}
final device = devices.firstWhereOrNull(
(item) => item.deviceId == effectiveSelectedDeviceId,
);
if (device == null) {
return const <SyncedTabItem>[];
}
final tabs = device.tabs
.map(
(tab) => SyncedTabItem(
deviceId: device.deviceId,
deviceName: device.deviceName,
tab: tab,
),
)
.toList(growable: false);
tabs.sort((a, b) => b.tab.lastUsed.compareTo(a.tab.lastUsed));
return tabs;
}
@Riverpod(keepAlive: true)
class SyncRepository extends _$SyncRepository {
final _service = GeckoSyncService();
StreamSubscription<SyncAccountInfo>? _authStateSub;
StreamSubscription<void>? _syncStartedSub;
StreamSubscription<void>? _syncCompletedSub;
StreamSubscription<String?>? _syncErrorSub;
Future<void> _awaitInitialized() => future;
void _update(SyncRepositoryState Function(SyncRepositoryState) updater) {
final current = state.value;
if (current != null) {
state = AsyncData(updater(current));
}
}
Future<void> _refreshAccount() async {
await _awaitInitialized();
final account = await _service.getAccountInfo();
_update((s) => s.copyWith(account: account));
}
Future<void> _refreshTabs() async {
await _awaitInitialized();
try {
final tabs = await _service.getSyncedTabs();
_update((s) => s.copyWith(remoteTabs: tabs));
} on Exception catch (e) {
logger.e('Failed to refresh synced tabs', error: e);
_update(
(s) => s.copyWith(
lastSyncEvent: SyncEvent.error,
lastSyncError: e.toString(),
),
);
}
}
Future<void> _refreshDevices() async {
await _awaitInitialized();
try {
final devices = await _service.getDevices();
_update((s) => s.copyWith(devices: devices));
} on Exception catch (e) {
logger.e('Failed to refresh devices', error: e);
_update(
(s) => s.copyWith(
lastSyncEvent: SyncEvent.error,
lastSyncError: e.toString(),
),
);
}
}
Future<void> _refreshDeviceName() async {
await _awaitInitialized();
final deviceName = await _service.getDeviceName();
_update((s) => s.copyWith(deviceName: deviceName));
}
Future<SyncAccountInfo> refresh() async {
await _refreshAccount();
return state.value!.account;
}
Future<void> signIn() async {
await _service.beginAuthentication();
}
Future<void> signInWithPairing(String pairingUrl) async {
await _service.beginPairingAuthentication(pairingUrl);
}
Future<void> signOut() async {
await _service.logout();
}
Future<void> syncNow() async {
await _service.syncNow();
await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]);
}
Future<void> setEngineEnabled(SyncEngineValue engine, bool enabled) async {
await _service.setEngineEnabled(engine, enabled);
await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]);
}
Future<bool> sendTabToDevice({
required String deviceId,
required String title,
required String url,
}) {
return _service.sendTabToDevice(deviceId, title, url);
}
Future<bool> setDeviceName(String newName) async {
final result = await _service.setDeviceName(newName);
if (result) {
await Future.wait([_refreshDevices(), _refreshDeviceName()]);
}
return result;
}
Future<void> refreshDevices() async {
await _service.refreshDevices();
await _refreshDevices();
}
Future<int> pollIncomingTabsAndOpen() async {
await _service.pollDeviceCommands();
final incomingTabs = await _service.drainIncomingTabs();
for (final tab in incomingTabs) {
await _openUrlInAssignedContainer(tab.url);
}
await _refreshTabs();
return incomingTabs.length;
}
Future<void> openSyncedTab(SyncRemoteTab tab) async {
await _openUrlInAssignedContainer(tab.url);
}
Future<void> _openUrlInAssignedContainer(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) {
return;
}
final containerRepository = ref.read(containerRepositoryProvider.notifier);
final containerId = await containerRepository.siteAssignedContainerId(uri);
final assignedContainer = containerId == null
? null
: await containerRepository.getContainerData(containerId);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
selectTab: true,
private: false,
container: Value(assignedContainer),
);
}
@override
Future<SyncRepositoryState> build() async {
final syncStateService = ref.read(geckoSyncStateServiceProvider);
_syncStartedSub = syncStateService.syncStartedEvents.listen((_) {
_update(
(s) => s.copyWith(
lastSyncEvent: SyncEvent.started,
// ignore: avoid_redundant_argument_values
lastSyncError: null,
),
);
});
_syncCompletedSub = syncStateService.syncCompletedEvents.listen((_) {
_update(
(s) => s.copyWith(
lastSyncEvent: SyncEvent.completed,
// ignore: avoid_redundant_argument_values
lastSyncError: null,
),
);
unawaited(Future.wait([_refreshTabs(), _refreshDevices()]));
});
_syncErrorSub = syncStateService.syncErrorEvents.listen((error) {
logger.e('Sync failed', error: error ?? 'Unknown synchronization error');
_update(
(s) => s.copyWith(lastSyncEvent: SyncEvent.error, lastSyncError: error),
);
unawaited(Future.wait([_refreshTabs(), _refreshDevices()]));
});
_authStateSub = syncStateService.authStateEvents.listen((info) {
final previous = state.value;
if (previous == null) {
state = AsyncData(SyncRepositoryState(account: info));
return;
}
final unauthenticated = !info.authenticated;
state = AsyncData(
unauthenticated
? SyncRepositoryState(account: info)
: previous.copyWith(account: info),
);
final authStateChanged =
previous.account.authenticated != info.authenticated ||
previous.account.needsReauth != info.needsReauth;
if (authStateChanged && info.authenticated) {
unawaited(
Future.wait([
_refreshAccount(),
_refreshTabs(),
_refreshDevices(),
_refreshDeviceName(),
]),
);
}
});
ref.onDispose(() {
unawaited(_authStateSub?.cancel());
unawaited(_syncStartedSub?.cancel());
unawaited(_syncCompletedSub?.cancel());
unawaited(_syncErrorSub?.cancel());
});
final (account, tabs, devices, deviceName) = await (
_service.getAccountInfo(),
_service.getSyncedTabs(),
_service.getDevices(),
_service.getDeviceName(),
).wait;
return SyncRepositoryState(
account: account,
remoteTabs: tabs,
devices: devices,
deviceName: deviceName,
);
}
}
@Riverpod(keepAlive: true)
GeckoSyncStateService geckoSyncStateService(Ref ref) {
final service = GeckoSyncStateService.setUp();
ref.onDispose(() => service.dispose());
return service;
}
@riverpod
Future<String?> syncDeviceName(Ref ref) {
return ref.watch(
syncRepositoryProvider.selectAsync((value) => value.deviceName),
);
}
@riverpod
Future<List<SyncDeviceTabs>> syncRemoteTabs(Ref ref) {
return ref.watch(
syncRepositoryProvider.selectAsync((value) => value.remoteTabs),
);
}
@riverpod
Future<List<SyncDevice>> syncDevices(Ref ref) {
return ref.watch(
syncRepositoryProvider.selectAsync((value) => value.devices),
);
}
@riverpod
Future<(SyncEvent?, String?)> syncEvent(Ref ref) {
return ref.watch(
syncRepositoryProvider.selectAsync(
(s) => (s.lastSyncEvent, s.lastSyncError),
),
);
}
@@ -0,0 +1,560 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(syncIsAuthenticated)
final syncIsAuthenticatedProvider = SyncIsAuthenticatedProvider._();
final class SyncIsAuthenticatedProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
SyncIsAuthenticatedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncIsAuthenticatedProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncIsAuthenticatedHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return syncIsAuthenticated(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$syncIsAuthenticatedHash() =>
r'40e108a5c1d4d885fd31edde04146bf4131dd51c';
@ProviderFor(TabsTrayScopeController)
final tabsTrayScopeControllerProvider = TabsTrayScopeControllerProvider._();
final class TabsTrayScopeControllerProvider
extends $NotifierProvider<TabsTrayScopeController, TabsTrayScope> {
TabsTrayScopeControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabsTrayScopeControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabsTrayScopeControllerHash();
@$internal
@override
TabsTrayScopeController create() => TabsTrayScopeController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabsTrayScope value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabsTrayScope>(value),
);
}
}
String _$tabsTrayScopeControllerHash() =>
r'e9415c1f57e1a78804ec4eee122c9db9ee416066';
abstract class _$TabsTrayScopeController extends $Notifier<TabsTrayScope> {
TabsTrayScope build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<TabsTrayScope, TabsTrayScope>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<TabsTrayScope, TabsTrayScope>,
TabsTrayScope,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(effectiveTabsTrayScope)
final effectiveTabsTrayScopeProvider = EffectiveTabsTrayScopeProvider._();
final class EffectiveTabsTrayScopeProvider
extends $FunctionalProvider<TabsTrayScope, TabsTrayScope, TabsTrayScope>
with $Provider<TabsTrayScope> {
EffectiveTabsTrayScopeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'effectiveTabsTrayScopeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveTabsTrayScopeHash();
@$internal
@override
$ProviderElement<TabsTrayScope> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
TabsTrayScope create(Ref ref) {
return effectiveTabsTrayScope(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabsTrayScope value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabsTrayScope>(value),
);
}
}
String _$effectiveTabsTrayScopeHash() =>
r'ac295d7413a7014882835d3533bf31cd3c031a1d';
@ProviderFor(SelectedSyncedTabsDeviceId)
final selectedSyncedTabsDeviceIdProvider =
SelectedSyncedTabsDeviceIdProvider._();
final class SelectedSyncedTabsDeviceIdProvider
extends $NotifierProvider<SelectedSyncedTabsDeviceId, String?> {
SelectedSyncedTabsDeviceIdProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedSyncedTabsDeviceIdProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedSyncedTabsDeviceIdHash();
@$internal
@override
SelectedSyncedTabsDeviceId create() => SelectedSyncedTabsDeviceId();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedSyncedTabsDeviceIdHash() =>
r'61f86d2f17d6c2f04e1fb76dae900c4695aa6af0';
abstract class _$SelectedSyncedTabsDeviceId extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(syncedTabsTotalCount)
final syncedTabsTotalCountProvider = SyncedTabsTotalCountProvider._();
final class SyncedTabsTotalCountProvider
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
with $FutureModifier<int>, $FutureProvider<int> {
SyncedTabsTotalCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncedTabsTotalCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncedTabsTotalCountHash();
@$internal
@override
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<int> create(Ref ref) {
return syncedTabsTotalCount(ref);
}
}
String _$syncedTabsTotalCountHash() =>
r'507b14aac187c434cf02925d895151c1a25159ea';
@ProviderFor(effectiveSyncedTabsDeviceId)
final effectiveSyncedTabsDeviceIdProvider =
EffectiveSyncedTabsDeviceIdProvider._();
final class EffectiveSyncedTabsDeviceIdProvider
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
with $FutureModifier<String?>, $FutureProvider<String?> {
EffectiveSyncedTabsDeviceIdProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'effectiveSyncedTabsDeviceIdProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveSyncedTabsDeviceIdHash();
@$internal
@override
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String?> create(Ref ref) {
return effectiveSyncedTabsDeviceId(ref);
}
}
String _$effectiveSyncedTabsDeviceIdHash() =>
r'26ed7e56191a8019cab5c89dc8d5622e1a709e20';
@ProviderFor(syncedTabsForSelectedDevice)
final syncedTabsForSelectedDeviceProvider =
SyncedTabsForSelectedDeviceProvider._();
final class SyncedTabsForSelectedDeviceProvider
extends
$FunctionalProvider<
AsyncValue<List<SyncedTabItem>>,
List<SyncedTabItem>,
FutureOr<List<SyncedTabItem>>
>
with
$FutureModifier<List<SyncedTabItem>>,
$FutureProvider<List<SyncedTabItem>> {
SyncedTabsForSelectedDeviceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncedTabsForSelectedDeviceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncedTabsForSelectedDeviceHash();
@$internal
@override
$FutureProviderElement<List<SyncedTabItem>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SyncedTabItem>> create(Ref ref) {
return syncedTabsForSelectedDevice(ref);
}
}
String _$syncedTabsForSelectedDeviceHash() =>
r'a85c4e972ab55b872f1de64cd27ba18d7dcc023a';
@ProviderFor(SyncRepository)
final syncRepositoryProvider = SyncRepositoryProvider._();
final class SyncRepositoryProvider
extends $AsyncNotifierProvider<SyncRepository, SyncRepositoryState> {
SyncRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncRepositoryHash();
@$internal
@override
SyncRepository create() => SyncRepository();
}
String _$syncRepositoryHash() => r'5312aed1abf60ce3e04afa7d2f60b38367c09312';
abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> {
FutureOr<SyncRepositoryState> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<SyncRepositoryState>, SyncRepositoryState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SyncRepositoryState>, SyncRepositoryState>,
AsyncValue<SyncRepositoryState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(geckoSyncStateService)
final geckoSyncStateServiceProvider = GeckoSyncStateServiceProvider._();
final class GeckoSyncStateServiceProvider
extends
$FunctionalProvider<
GeckoSyncStateService,
GeckoSyncStateService,
GeckoSyncStateService
>
with $Provider<GeckoSyncStateService> {
GeckoSyncStateServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'geckoSyncStateServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$geckoSyncStateServiceHash();
@$internal
@override
$ProviderElement<GeckoSyncStateService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoSyncStateService create(Ref ref) {
return geckoSyncStateService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoSyncStateService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoSyncStateService>(value),
);
}
}
String _$geckoSyncStateServiceHash() =>
r'1e96db4a6229a3d2a31862cc9cf88c463405819b';
@ProviderFor(syncDeviceName)
final syncDeviceNameProvider = SyncDeviceNameProvider._();
final class SyncDeviceNameProvider
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
with $FutureModifier<String?>, $FutureProvider<String?> {
SyncDeviceNameProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncDeviceNameProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncDeviceNameHash();
@$internal
@override
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String?> create(Ref ref) {
return syncDeviceName(ref);
}
}
String _$syncDeviceNameHash() => r'3d7e531cba481c4233a895b1d80e915d646fc6b6';
@ProviderFor(syncRemoteTabs)
final syncRemoteTabsProvider = SyncRemoteTabsProvider._();
final class SyncRemoteTabsProvider
extends
$FunctionalProvider<
AsyncValue<List<SyncDeviceTabs>>,
List<SyncDeviceTabs>,
FutureOr<List<SyncDeviceTabs>>
>
with
$FutureModifier<List<SyncDeviceTabs>>,
$FutureProvider<List<SyncDeviceTabs>> {
SyncRemoteTabsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncRemoteTabsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncRemoteTabsHash();
@$internal
@override
$FutureProviderElement<List<SyncDeviceTabs>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SyncDeviceTabs>> create(Ref ref) {
return syncRemoteTabs(ref);
}
}
String _$syncRemoteTabsHash() => r'5040a010e578f2fe395302e7d34a91df1854f8a9';
@ProviderFor(syncDevices)
final syncDevicesProvider = SyncDevicesProvider._();
final class SyncDevicesProvider
extends
$FunctionalProvider<
AsyncValue<List<SyncDevice>>,
List<SyncDevice>,
FutureOr<List<SyncDevice>>
>
with $FutureModifier<List<SyncDevice>>, $FutureProvider<List<SyncDevice>> {
SyncDevicesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncDevicesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncDevicesHash();
@$internal
@override
$FutureProviderElement<List<SyncDevice>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SyncDevice>> create(Ref ref) {
return syncDevices(ref);
}
}
String _$syncDevicesHash() => r'6249b4891f95fc6f8e2f70f1555aff0e6356ccad';
@ProviderFor(syncEvent)
final syncEventProvider = SyncEventProvider._();
final class SyncEventProvider
extends
$FunctionalProvider<
AsyncValue<(SyncEvent?, String?)>,
(SyncEvent?, String?),
FutureOr<(SyncEvent?, String?)>
>
with
$FutureModifier<(SyncEvent?, String?)>,
$FutureProvider<(SyncEvent?, String?)> {
SyncEventProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncEventProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncEventHash();
@$internal
@override
$FutureProviderElement<(SyncEvent?, String?)> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<(SyncEvent?, String?)> create(Ref ref) {
return syncEvent(ref);
}
}
String _$syncEventHash() => r'c84d251502578779f66e17ee79e353ffa3b23e1a';
@@ -0,0 +1,463 @@
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'),
),
],
);
},
);
}
}
@@ -89,6 +89,8 @@ class GeneralSettings with FastEquatable {
final bool tabListShowFavicons;
final bool quickTabSwitcherShowTitles;
final bool drawerGestureEnabled;
final String syncServerOverride;
final String syncTokenServerOverride;
GeneralSettings({
required this.themeMode,
@@ -120,6 +122,8 @@ class GeneralSettings with FastEquatable {
required this.tabListShowFavicons,
required this.quickTabSwitcherShowTitles,
required this.drawerGestureEnabled,
required this.syncServerOverride,
required this.syncTokenServerOverride,
});
GeneralSettings.withDefaults({
@@ -152,6 +156,8 @@ class GeneralSettings with FastEquatable {
bool? tabListShowFavicons,
bool? quickTabSwitcherShowTitles,
bool? drawerGestureEnabled,
String? syncServerOverride,
String? syncTokenServerOverride,
}) : themeMode = themeMode ?? ThemeMode.dark,
enableReadability = enableReadability ?? true,
enforceReadability = enforceReadability ?? false,
@@ -184,7 +190,9 @@ class GeneralSettings with FastEquatable {
allowClipboardAccess = allowClipboardAccess ?? true,
tabListShowFavicons = tabListShowFavicons ?? false,
quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true,
drawerGestureEnabled = drawerGestureEnabled ?? false;
drawerGestureEnabled = drawerGestureEnabled ?? false,
syncServerOverride = syncServerOverride ?? '',
syncTokenServerOverride = syncTokenServerOverride ?? '';
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
@@ -222,5 +230,7 @@ class GeneralSettings with FastEquatable {
tabListShowFavicons,
quickTabSwitcherShowTitles,
drawerGestureEnabled,
syncServerOverride,
syncTokenServerOverride,
];
}
@@ -77,6 +77,10 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled);
GeneralSettings syncServerOverride(String syncServerOverride);
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
///
@@ -114,6 +118,8 @@ abstract class _$GeneralSettingsCWProxy {
bool tabListShowFavicons,
bool quickTabSwitcherShowTitles,
bool drawerGestureEnabled,
String syncServerOverride,
String syncTokenServerOverride,
});
}
@@ -245,6 +251,14 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled) =>
call(drawerGestureEnabled: drawerGestureEnabled);
@override
GeneralSettings syncServerOverride(String syncServerOverride) =>
call(syncServerOverride: syncServerOverride);
@override
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride) =>
call(syncTokenServerOverride: syncTokenServerOverride);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
@@ -283,6 +297,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? tabListShowFavicons = const $CopyWithPlaceholder(),
Object? quickTabSwitcherShowTitles = const $CopyWithPlaceholder(),
Object? drawerGestureEnabled = const $CopyWithPlaceholder(),
Object? syncServerOverride = const $CopyWithPlaceholder(),
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
}) {
return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -455,6 +471,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.drawerGestureEnabled
// ignore: cast_nullable_to_non_nullable
: drawerGestureEnabled as bool,
syncServerOverride:
syncServerOverride == const $CopyWithPlaceholder() ||
syncServerOverride == null
? _value.syncServerOverride
// ignore: cast_nullable_to_non_nullable
: syncServerOverride as String,
syncTokenServerOverride:
syncTokenServerOverride == const $CopyWithPlaceholder() ||
syncTokenServerOverride == null
? _value.syncTokenServerOverride
// ignore: cast_nullable_to_non_nullable
: syncTokenServerOverride as String,
);
}
}
@@ -534,6 +562,8 @@ GeneralSettings _$GeneralSettingsFromJson(
tabListShowFavicons: json['tabListShowFavicons'] as bool?,
quickTabSwitcherShowTitles: json['quickTabSwitcherShowTitles'] as bool?,
drawerGestureEnabled: json['drawerGestureEnabled'] as bool?,
syncServerOverride: json['syncServerOverride'] as String?,
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
);
Map<String, dynamic> _$GeneralSettingsToJson(
@@ -577,6 +607,8 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'tabListShowFavicons': instance.tabListShowFavicons,
'quickTabSwitcherShowTitles': instance.quickTabSwitcherShowTitles,
'drawerGestureEnabled': instance.drawerGestureEnabled,
'syncServerOverride': instance.syncServerOverride,
'syncTokenServerOverride': instance.syncTokenServerOverride,
};
const _$ThemeModeEnumMap = {
@@ -60,9 +60,7 @@ class ProfileBackupListScreen extends HookConsumerWidget {
key: ValueKey(file.path),
title: Text(profileName),
subtitle: Text(
ref
.read(formatProvider.notifier)
.fullDateTimeWithTimezone(dateTime),
ref.read(formatProvider.notifier).fullDateTime(dateTime),
),
onTap: () async {
await RestoreProfileRoute(
@@ -155,6 +155,14 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.bool,
db.typeMapping,
),
'syncServerOverride': settings['syncServerOverride']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'syncTokenServerOverride': settings['syncTokenServerOverride']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'33730ccb09471c1a7c187bd19760110f524e055b';
r'ed5bca200b840e0802980b85a0c0f682d7b24a8c';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {
@@ -147,13 +147,13 @@ class FeedArticleScreen extends HookConsumerWidget {
children: [
const Divider(),
Text(
'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.created!) : 'N/A'}',
'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTime(article.created!) : 'N/A'}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
if (hasArticleUpdated)
Text(
'Updated: ${ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.updated!)}',
'Updated: ${ref.read(formatProvider.notifier).fullDateTime(article.updated!)}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
+6
View File
@@ -17,6 +17,7 @@
* 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 'dart:developer';
import 'package:background_fetch/background_fetch.dart';
@@ -101,6 +102,9 @@ class _MainWidget extends HookConsumerWidget {
final engineSettings = await ref
.read(engineSettingsRepositoryProvider.notifier)
.fetchSettings();
final generalSettings = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
try {
await GeckoBrowserService().initialize(
@@ -108,6 +112,8 @@ class _MainWidget extends HookConsumerWidget {
kDebugMode ? LogLevel.debug : LogLevel.warn,
engineSettings.contentBlocking,
engineSettings.addonCollection,
generalSettings.syncServerOverride,
generalSettings.syncTokenServerOverride,
);
} on PlatformException catch (e, s) {
logger.e(
+44
View File
@@ -21,7 +21,10 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/domain/services/app_initialization.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class MainApp extends HookConsumerWidget {
final ThemeData? theme;
@@ -72,6 +75,9 @@ class MainApp extends HookConsumerWidget {
darkTheme: darkTheme,
themeMode: themeMode,
routerConfig: router.value,
builder: (context, child) {
return _SyncEventListener(child: child ?? const SizedBox.shrink());
},
);
},
onFailure: (errorMessage) {
@@ -99,3 +105,41 @@ class MainApp extends HookConsumerWidget {
);
}
}
class _SyncEventListener extends ConsumerWidget {
final Widget child;
const _SyncEventListener({required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(syncEventProvider, (previous, next) {
if (next.isLoading || !next.hasValue) return;
final event = next.value;
if (event == null) return;
final (syncEvent, syncError) = event;
switch (syncEvent) {
// case SyncEvent.completed:
// ui_helper.showInfoMessage(
// context,
// 'Synchronization complete',
// duration: const Duration(seconds: 2),
// );
case SyncEvent.error:
ui_helper.showErrorMessage(
context,
syncError ?? 'Synchronization failed',
);
case SyncEvent.started:
case SyncEvent.completed:
case null:
break;
}
});
return child;
}
}
+52 -7
View File
@@ -30,8 +30,8 @@ SnackBar _createFloatingSnackBar({
required Widget content,
Color? backgroundColor,
SnackBarAction? action,
Duration duration = const Duration(seconds: 4),
bool persist = false,
required Duration duration,
required bool persist,
}) {
return SnackBar(
content: content,
@@ -43,34 +43,69 @@ SnackBar _createFloatingSnackBar({
);
}
void showErrorMessage(BuildContext context, String message) {
void showErrorMessage(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
final snackBar = _createFloatingSnackBar(
content: Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
backgroundColor: Theme.of(context).colorScheme.onError,
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showInfoMessage(BuildContext context, String message) {
final snackBar = _createFloatingSnackBar(content: Text(message));
void showInfoMessage(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
final snackBar = _createFloatingSnackBar(
content: Text(message),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showOpenedTabsFromAnotherDeviceMessage(
BuildContext context,
int openedTabs, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
if (openedTabs <= 0) {
return;
}
final message = openedTabs == 1
? 'Opened 1 tab received from another device'
: 'Opened $openedTabs tabs received from another device';
showInfoMessage(context, message, duration: duration, persist: persist);
}
void showTabBackButtonMessage(
BuildContext context,
int tabCount,
Duration duration,
) {
Duration duration, {
bool persist = false,
}) {
final snackbar = _createFloatingSnackBar(
content: (tabCount > 1)
? const Text('Navigate BACK again to close current tab')
: const Text('Navigate BACK again to exit app'),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context)
@@ -83,6 +118,7 @@ void showTabOpenedMessage(
String? tabName,
void Function()? onShow,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) {
final message = switch (tabName.whenNotEmpty) {
String() => "New tab '$tabName' opened in background",
@@ -95,6 +131,7 @@ void showTabOpenedMessage(
(onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed),
),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
@@ -104,6 +141,7 @@ Future<void> showSuggestNewTabMessage(
BuildContext context, {
required void Function(String? searchText) onAdd,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) async {
final clipboardUrl = await tryGetUriFromClipboard();
@@ -117,6 +155,7 @@ Future<void> showSuggestNewTabMessage(
},
),
duration: duration,
persist: persist,
);
if (context.mounted) {
@@ -130,6 +169,7 @@ void showTabSwitchMessage(
String? tabName,
void Function()? onSwitch,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
@@ -144,6 +184,7 @@ void showTabSwitchMessage(
(onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed),
),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
@@ -179,6 +220,7 @@ void showTabUndoClose(
VoidCallback onUndo, {
int count = 1,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
@@ -188,6 +230,7 @@ void showTabUndoClose(
: const Text('Tab closed'),
action: SnackBarAction(label: 'Undo', onPressed: onUndo),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
@@ -197,6 +240,7 @@ void showDismissOverrideMessage(
BuildContext context,
VoidCallback onDismiss, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
@@ -204,6 +248,7 @@ void showDismissOverrideMessage(
content: const Text('Hiding disabled by site'),
action: SnackBarAction(label: 'Dismiss', onPressed: onDismiss),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);