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
@@ -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)