diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 892328c9..cd0b67d2 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -300,6 +300,20 @@ class TabRepository extends _$TabRepository { return selectTab(latestTab.id); } + Future resumeLatestContainerTab(String? containerId) async { + final latestTab = await ref + .read(tabDatabaseProvider) + .tabDao + .getContainerTabsFifo(containerId, limit: 1) + .getSingleOrNull(); + + if (!ref.mounted || latestTab == null) { + return false; + } + + return selectTab(latestTab.id); + } + Future selectPreviousTab( String tabId, { String? containerId, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart index c6b04345..b7a69e2f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart @@ -9,6 +9,9 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/quotes/data/database/definitions.drift.dart'; import 'package:weblibre/features/quotes/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -39,6 +42,18 @@ class BrowserHome extends ConsumerWidget { browserViewportToolbarInsetsControllerProvider, ); + final containerData = ref.watch( + selectedContainerDataProvider.select((value) => value.value), + ); + final hasContainerTabs = ref.watch( + selectedContainerTabCountProvider.select( + (data) => switch (data) { + AsyncData(:final value) => value > 0, + _ => false, + }, + ), + ); + final pixelRatio = MediaQuery.devicePixelRatioOf(context); final bottomViewportInset = @@ -58,6 +73,13 @@ class BrowserHome extends ConsumerWidget { await ref.read(tabRepositoryProvider.notifier).resumeLatestTab(); } + Future resumeLatestContainerTab() async { + final containerId = ref.read(selectedContainerProvider); + await ref + .read(tabRepositoryProvider.notifier) + .resumeLatestContainerTab(containerId); + } + return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -135,49 +157,10 @@ class BrowserHome extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 112, - height: 112, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color.alphaBlend( - _brandPurple.withValues(alpha: 0.18), - colorScheme.surfaceContainerHighest, - ), - Color.alphaBlend( - _brandYellow.withValues(alpha: 0.12), - colorScheme.surfaceContainer, - ), - ], - ), - border: Border.all( - color: colorScheme.outlineVariant.withValues( - alpha: 0.45, - ), - ), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withValues( - alpha: 0.08, - ), - blurRadius: 32, - offset: const Offset(0, 18), - ), - ], - ), - child: Center( - child: SvgPicture.asset( - 'assets/icon/icon.svg', - width: 72, - height: 72, - ), - ), - ), + if (containerData != null) + _ContainerHeader(container: containerData) + else + _BrandHeader(colorScheme: colorScheme), const SizedBox(height: 24), if (!hasTabs) ...[ Text( @@ -197,6 +180,28 @@ class BrowserHome extends ConsumerWidget { ), ), const SizedBox(height: 28), + ] else if (containerData != null) ...[ + Text( + containerData.name?.isNotEmpty == true + ? containerData.name! + : 'Container', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Text( + hasContainerTabs + ? 'No matching tab selected' + : 'No open tabs in this container', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: 28), ], Container( width: double.infinity, @@ -287,7 +292,13 @@ class BrowserHome extends ConsumerWidget { icon: const Icon(Icons.add_rounded), label: const Text('New tab'), ), - if (hasTabs) + if (hasContainerTabs) + FilledButton.tonalIcon( + onPressed: resumeLatestContainerTab, + icon: const Icon(Icons.history_rounded), + label: const Text('Resume last tab'), + ) + else if (hasTabs && containerData == null) FilledButton.tonalIcon( onPressed: resumeLatestTab, icon: const Icon(Icons.history_rounded), @@ -309,6 +320,102 @@ class BrowserHome extends ConsumerWidget { } } +class _BrandHeader extends StatelessWidget { + final ColorScheme colorScheme; + + const _BrandHeader({required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return Container( + width: 112, + height: 112, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.alphaBlend( + BrowserHome._brandPurple.withValues(alpha: 0.18), + colorScheme.surfaceContainerHighest, + ), + Color.alphaBlend( + BrowserHome._brandYellow.withValues(alpha: 0.12), + colorScheme.surfaceContainer, + ), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.45), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 32, + offset: const Offset(0, 18), + ), + ], + ), + child: Center( + child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72), + ), + ); + } +} + +class _ContainerHeader extends StatelessWidget { + final ContainerData container; + + const _ContainerHeader({required this.container}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final containerColor = container.color; + + return Container( + width: 112, + height: 112, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.alphaBlend( + ContainerColors.forChip(containerColor), + colorScheme.surfaceContainerHighest, + ), + Color.alphaBlend( + containerColor.withValues(alpha: 0.12), + colorScheme.surfaceContainer, + ), + ], + ), + border: Border.all( + color: Color.alphaBlend( + containerColor.withValues(alpha: 0.25), + colorScheme.outlineVariant.withValues(alpha: 0.45), + ), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 32, + offset: const Offset(0, 18), + ), + ], + ), + child: Center( + child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72), + ), + ); + } +} + class _QuoteBlock extends StatelessWidget { final Quote? quote; diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 132472f3..9dd0ab57 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -34,7 +34,6 @@ import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart'; -import 'package:weblibre/features/geckoview/domain/providers/selected_tab.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/domain/providers/web_extensions_state.dart'; @@ -50,6 +49,7 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; 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/share_intent/domain/entities/shared_content.dart'; @@ -150,9 +150,7 @@ class _BrowserViewState extends ConsumerState } }); - final hasTab = ref.watch( - selectedTabProvider.select((value) => value != null), - ); + final showHome = ref.watch(shouldShowBrowserHomeProvider); final topRoute = ref.watch(currentTopRouteProvider); final androidInfoAsync = ref.watch(androidDeviceInfoProvider); @@ -291,7 +289,7 @@ class _BrowserViewState extends ConsumerState }, ), ), - if (!hasTab) const Positioned.fill(child: BrowserHome()), + if (showHome) const Positioned.fill(child: BrowserHome()), ], ), ); diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index b8be466d..fb426161 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -101,6 +101,15 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]); } + Selectable getContainerTabsFifo(String? containerId, {int limit = 25}) { + return select(db.tab) + ..where((t) => containerId != null + ? t.containerId.equals(containerId) + : t.containerId.isNull()) + ..limit(limit) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]); + } + SingleOrNullSelectable getTabContainerId(String tabId) { final query = selectOnly(db.tab) ..addColumns([db.tab.containerId]) diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index 52079111..7a04ad21 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -26,6 +26,7 @@ import 'package:riverpod_annotation/experimental/persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; @@ -165,6 +166,37 @@ Stream selectedContainerData(Ref ref) { return Stream.value(null); } +/// Whether the browser home screen should be displayed instead of the +/// active tab's content. +/// +/// Returns `true` when any of the following hold: +/// 1. No tab is selected at all (app just started or all tabs closed). +/// 2. The selected tab belongs to a different container than the currently +/// selected container – this implies the user manually switched +/// containers after selecting a tab, because tab selection automatically +/// syncs the selected container to match the tab's container. +/// +/// Condition (2) also implicitly covers the case where the selected +/// container has zero tabs: if the container has no tabs, the selected tab +/// (if any) necessarily belongs to a different container. +@Riverpod() +bool shouldShowBrowserHome(Ref ref) { + final selectedTab = ref.watch(selectedTabProvider); + + // No tab selected → always show home. + if (selectedTab == null) return true; + + final selectedContainer = ref.watch(selectedContainerProvider); + final tabContainerId = ref.watch(selectedTabContainerIdProvider); + + // Once we know the tab's container, compare with the selected container. + return switch (tabContainerId) { + AsyncData(:final value) => value != selectedContainer, + // While loading, keep the current view to avoid flashing. + _ => false, + }; +} + @Riverpod() Future selectedContainerTabCount(Ref ref) async { final selectedContainer = ref.watch(selectedContainerProvider); diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index fa2e6b6c..362b9d7c 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -41,7 +41,7 @@ final class SelectedContainerProvider } } -String _$selectedContainerHash() => r'1e2c67dc70fedb2b910640fbfbe09f2c4806894b'; +String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417'; abstract class _$SelectedContainer extends $Notifier { String? build(); @@ -101,6 +101,89 @@ final class SelectedContainerDataProvider String _$selectedContainerDataHash() => r'1ec86a82e1fc4823a867285f05036c903633a165'; +/// Whether the browser home screen should be displayed instead of the +/// active tab's content. +/// +/// Returns `true` when any of the following hold: +/// 1. No tab is selected at all (app just started or all tabs closed). +/// 2. The selected tab belongs to a different container than the currently +/// selected container – this implies the user manually switched +/// containers after selecting a tab, because tab selection automatically +/// syncs the selected container to match the tab's container. +/// +/// Condition (2) also implicitly covers the case where the selected +/// container has zero tabs: if the container has no tabs, the selected tab +/// (if any) necessarily belongs to a different container. + +@ProviderFor(shouldShowBrowserHome) +final shouldShowBrowserHomeProvider = ShouldShowBrowserHomeProvider._(); + +/// Whether the browser home screen should be displayed instead of the +/// active tab's content. +/// +/// Returns `true` when any of the following hold: +/// 1. No tab is selected at all (app just started or all tabs closed). +/// 2. The selected tab belongs to a different container than the currently +/// selected container – this implies the user manually switched +/// containers after selecting a tab, because tab selection automatically +/// syncs the selected container to match the tab's container. +/// +/// Condition (2) also implicitly covers the case where the selected +/// container has zero tabs: if the container has no tabs, the selected tab +/// (if any) necessarily belongs to a different container. + +final class ShouldShowBrowserHomeProvider + extends $FunctionalProvider + with $Provider { + /// Whether the browser home screen should be displayed instead of the + /// active tab's content. + /// + /// Returns `true` when any of the following hold: + /// 1. No tab is selected at all (app just started or all tabs closed). + /// 2. The selected tab belongs to a different container than the currently + /// selected container – this implies the user manually switched + /// containers after selecting a tab, because tab selection automatically + /// syncs the selected container to match the tab's container. + /// + /// Condition (2) also implicitly covers the case where the selected + /// container has zero tabs: if the container has no tabs, the selected tab + /// (if any) necessarily belongs to a different container. + ShouldShowBrowserHomeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'shouldShowBrowserHomeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$shouldShowBrowserHomeHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return shouldShowBrowserHome(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$shouldShowBrowserHomeHash() => + r'644344c9abe06e0273dad584e75a53dc417781ad'; + @ProviderFor(selectedContainerTabCount) final selectedContainerTabCountProvider = SelectedContainerTabCountProvider._();