diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index 89b79531..100753b3 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -179,6 +179,19 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
+
+
+
+
{
Format create() => Format();
}
-String _$formatHash() => r'fe7fcdd19b512784a36f3838c57701030475e429';
+String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff';
abstract class _$Format extends $AsyncNotifier {
FutureOr build();
diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart
index 03c27cda..11299079 100644
--- a/app/lib/core/routing/routes.dart
+++ b/app/lib/core/routing/routes.dart
@@ -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';
diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart
index 044e2583..7e4dcd7f 100644
--- a/app/lib/core/routing/routes.g.dart
+++ b/app/lib/core/routing/routes.g.dart
@@ -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 push(BuildContext context) => context.push(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',
diff --git a/app/lib/core/routing/routes.settings.dart b/app/lib/core/routing/routes.settings.dart
index 92b1b984..d335ffa4 100644
--- a/app/lib/core/routing/routes.settings.dart
+++ b/app/lib/core/routing/routes.settings.dart
@@ -80,6 +80,7 @@ part of 'routes.dart';
path: 'custom_tracking_protection',
),
TypedGoRoute(name: 'ErrorLogsRoute', path: 'error_logs'),
+ TypedGoRoute(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();
+ }
+}
diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
index fa6bc491..9c46e3c6 100644
--- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
@@ -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();
// 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,
diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart
index 350302f8..bfea791b 100644
--- a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart
@@ -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),
+ ),
+ ),
+ ),
),
);
}
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
index f04682af..ca393331 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
@@ -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) {
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart
index d457b147..5066b511 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart
@@ -18,6 +18,8 @@
* along with this program. If not, see .
*/
+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(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();
+ }
+ },
+ );
+ }
+}
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart
index 1ac7a31e..e92b860e 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart
@@ -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'),
+ ),
+ );
+ }
+}
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart
index b2587694..083123a3 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart
@@ -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),
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart
index 5ca974d8..662c877f 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart
@@ -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),
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart
index a8fb159a..36ffe269 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart
@@ -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;
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart
index d540d12a..b6aeeb9d 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart
@@ -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(
+ 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(
- 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),
diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart
index 6ac21320..15fc3c06 100644
--- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart
+++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart
@@ -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,
),
diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart
index 0d048aa8..58695e67 100644
--- a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart
+++ b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart
@@ -186,7 +186,7 @@ class ContainerDao extends DatabaseAccessor
);
}
- SingleSelectable siteAssignedContainerId(Uri uri) {
+ Selectable siteAssignedContainerId(Uri uri) {
return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin);
}
diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart
index 7d8992e9..8adacfe7 100644
--- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart
+++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart
@@ -174,7 +174,8 @@ class ContainerRepository extends _$ContainerRepository {
.read(tabDatabaseProvider)
.containerDao
.siteAssignedContainerId(uri)
- .getSingle();
+ .get()
+ .then((value) => value.firstOrNull);
}
Future> getContainersToClearOnExit() async {
diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart
index 355b8134..905edbac 100644
--- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart
+++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
- r'62c2b06970db4385ea0d2edc68bcf076c291a9b6';
+ r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f';
abstract class _$ContainerRepository extends $Notifier {
void build();
diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart
index 0285dbfb..623a6cdd 100644
--- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart
+++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart
@@ -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)
diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart
index 10bc3e29..d5563a45 100644
--- a/app/lib/features/settings/presentation/screens/settings.dart
+++ b/app/lib/features/settings/presentation/screens/settings.dart
@@ -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();
diff --git a/app/lib/features/sync/domain/entities/sync_repository_state.dart b/app/lib/features/sync/domain/entities/sync_repository_state.dart
new file mode 100644
index 00000000..7f86b17c
--- /dev/null
+++ b/app/lib/features/sync/domain/entities/sync_repository_state.dart
@@ -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 remoteTabs;
+ final List devices;
+ final String? deviceName;
+ final SyncEvent? lastSyncEvent;
+ final String? lastSyncError;
+
+ SyncRepositoryState({
+ required this.account,
+ this.remoteTabs = const [],
+ this.devices = const [],
+ this.deviceName,
+ this.lastSyncEvent,
+ this.lastSyncError,
+ });
+
+ @override
+ List