From 4f0dc919247a51b4b505e8616f33221658b2ba08 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sat, 14 Mar 2026 09:14:07 +0100 Subject: [PATCH] turn empty tab screen into home screen --- .../geckoview/domain/repositories/tab.dart | 14 ++ .../{tabs_empty.dart => browser_home.dart} | 145 ++++++++++++------ .../browser_modules/app_bar_title.dart | 56 ++++++- .../browser_modules/bottom_app_bar.dart | 4 +- .../widgets/browser_modules/browser_view.dart | 4 +- .../GlobalComponents.kt | 35 ++++- 6 files changed, 204 insertions(+), 54 deletions(-) rename app/lib/features/geckoview/features/browser/presentation/widgets/{tabs_empty.dart => browser_home.dart} (72%) diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 5d278785..892328c9 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -286,6 +286,20 @@ class TabRepository extends _$TabRepository { return false; } + Future resumeLatestTab() async { + final latestTab = await ref + .read(tabDatabaseProvider) + .tabDao + .getTabsFifo(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/tabs_empty.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart similarity index 72% rename from app/lib/features/geckoview/features/browser/presentation/widgets/tabs_empty.dart rename to app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart index 9770f9cb..c6b04345 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_empty.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart @@ -1,28 +1,40 @@ import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/quotes/data/database/definitions.drift.dart'; import 'package:weblibre/features/quotes/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; -class TabsEmptyPlaceholder extends ConsumerWidget { - const TabsEmptyPlaceholder(); +class BrowserHome extends ConsumerWidget { + const BrowserHome({super.key}); static const _brandPurple = Color(0xFF9C83F8); static const _brandYellow = Color(0xFFFBDC6B); static const _brandGrey = Color(0xFFA7A7A7); + static const _auraPurple = Color(0xFF2C2543); + static const _auraGold = Color(0xFF3C3827); + static const _auraShadow = Color(0xFF222224); + static const _auraShadowHighlight = Color(0xFF2D2D31); + static const _auraTint = Color(0xFF000000); + @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final settings = ref.watch(generalSettingsWithDefaultsProvider); final quoteAsync = ref.watch(randomQuoteProvider); + final hasTabs = ref.watch( + tabListProvider.select((tabs) => tabs.value.isNotEmpty), + ); final viewportToolbarInsets = ref.watch( browserViewportToolbarInsetsControllerProvider, ); @@ -38,6 +50,14 @@ class TabsEmptyPlaceholder extends ConsumerWidget { ).push(context); } + Future viewTabs() { + return const TabViewRoute().push(context); + } + + Future resumeLatestTab() async { + await ref.read(tabRepositoryProvider.notifier).resumeLatestTab(); + } + return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -45,12 +65,15 @@ class TabsEmptyPlaceholder extends ConsumerWidget { end: Alignment.bottomRight, colors: [ Color.alphaBlend( - _brandPurple.withValues(alpha: 0.06), + _auraPurple.withValues(alpha: 0.38), colorScheme.surfaceContainerLowest, ), - colorScheme.surface, Color.alphaBlend( - _brandYellow.withValues(alpha: 0.05), + _auraShadow.withValues(alpha: 0.72), + colorScheme.surface, + ), + Color.alphaBlend( + _auraGold.withValues(alpha: 0.34), colorScheme.surfaceContainerHigh, ), ], @@ -59,25 +82,33 @@ class TabsEmptyPlaceholder extends ConsumerWidget { child: Stack( fit: StackFit.expand, children: [ - Align( - alignment: const Alignment(-0.9, -0.95), + const Positioned( + top: -70, + left: -120, + child: _BackdropOrb(width: 400, height: 400, color: _auraPurple), + ), + const Positioned( + top: 220, + right: -150, + child: _BackdropOrb(width: 340, height: 340, color: _auraGold), + ), + const Positioned( + bottom: 18, + left: -8, child: _BackdropOrb( - size: 220, - color: _brandPurple.withValues(alpha: 0.14), + width: 320, + height: 320, + color: _auraShadowHighlight, ), ), - Align( - alignment: const Alignment(1.0, -0.35), - child: _BackdropOrb( - size: 180, - color: _brandYellow.withValues(alpha: 0.14), - ), - ), - Align( - alignment: const Alignment(-0.75, 0.9), - child: _BackdropOrb( - size: 160, - color: _brandGrey.withValues(alpha: 0.12), + Positioned.fill( + child: IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72), + child: ColoredBox(color: _auraTint.withValues(alpha: 0.12)), + ), + ), ), ), LayoutBuilder( @@ -148,23 +179,25 @@ class TabsEmptyPlaceholder extends ConsumerWidget { ), ), const SizedBox(height: 24), - Text( - 'WebLibre is ready', - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, + if (!hasTabs) ...[ + Text( + 'WebLibre is ready', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 10), - Text( - 'A browser that respects you. Open a tab and experience the web, libre.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.45, + const SizedBox(height: 10), + Text( + 'A browser that respects you. Open a tab and experience the web, libre.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.45, + ), ), - ), - const SizedBox(height: 28), + const SizedBox(height: 28), + ], Container( width: double.infinity, padding: const EdgeInsets.all(20), @@ -238,10 +271,29 @@ class TabsEmptyPlaceholder extends ConsumerWidget { ), ), const SizedBox(height: 24), - FilledButton.icon( - onPressed: openNewTab, - icon: const Icon(Icons.add_rounded), - label: const Text('Open new tab'), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + if (hasTabs) + OutlinedButton.icon( + onPressed: viewTabs, + icon: const Icon(Icons.tab_rounded), + label: const Text('View tabs'), + ), + FilledButton.icon( + onPressed: openNewTab, + icon: const Icon(Icons.add_rounded), + label: const Text('New tab'), + ), + if (hasTabs) + FilledButton.tonalIcon( + onPressed: resumeLatestTab, + icon: const Icon(Icons.history_rounded), + label: const Text('Resume last tab'), + ), + ], ), ], ), @@ -310,17 +362,22 @@ class _QuoteBlock extends StatelessWidget { } class _BackdropOrb extends StatelessWidget { - final double size; + final double width; + final double height; final Color color; - const _BackdropOrb({required this.size, required this.color}); + const _BackdropOrb({ + required this.width, + required this.height, + required this.color, + }); @override Widget build(BuildContext context) { return IgnorePointer( child: Container( - width: size, - height: size, + width: width, + height: height, decoration: BoxDecoration(shape: BoxShape.circle, color: color), ), ); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index 81d96f4a..ba8ff83d 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/provid import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class CompactAppBarTitle extends ConsumerWidget { @@ -41,13 +42,21 @@ class CompactAppBarTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(selectedTabStateProvider); + final selectedTabType = ref.watch(selectedTabTypeProvider); + final settings = ref.watch(generalSettingsWithDefaultsProvider); final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); final showSiteSettingsBadge = ref.watch( showSiteSettingsBadgeProvider.select((value) => value.value == true), ); if (tabState == null) { - return const SizedBox.shrink(); + return _EmptyAppBarAddressField( + onTap: () async { + await SearchRoute( + tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType, + ).push(context); + }, + ); } return CompactAppBarTitleView( @@ -178,13 +187,21 @@ class AppBarTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(selectedTabStateProvider); + final selectedTabType = ref.watch(selectedTabTypeProvider); + final settings = ref.watch(generalSettingsWithDefaultsProvider); final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); final showSiteSettingsBadge = ref.watch( showSiteSettingsBadgeProvider.select((value) => value.value == true), ); if (tabState == null) { - return const SizedBox.shrink(); + return _EmptyAppBarAddressField( + onTap: () async { + await SearchRoute( + tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType, + ).push(context); + }, + ); } return AppBarTitleView( @@ -360,6 +377,41 @@ class _SecurityStatusIcon extends StatelessWidget { } } +class _EmptyAppBarAddressField extends StatelessWidget { + const _EmptyAppBarAddressField({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + ), + alignment: Alignment.center, + child: Text( + 'Search or enter URL', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + } +} + String _searchTextForTab(TabState tabState) { final searchText = tabState.url.scheme == 'about' ? '' 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 fc05dc53..ad058f87 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 @@ -114,7 +114,6 @@ class BrowserBottomAppBar extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Material( - elevation: 0, shadowColor: Colors.transparent, surfaceTintColor: Colors.transparent, color: colorScheme.surfaceContainer, @@ -211,8 +210,7 @@ class BrowserTabBar extends HookConsumerWidget { final dragStartPosition = useRef(Offset.zero); - final showTabTitle = - selectedTabId != null && displayedSheet is! ViewTabsSheet; + final showTabTitle = displayedSheet is! ViewTabsSheet; final backgroundColor = (settings.showContainerUi && 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 93ac9a6c..132472f3 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 @@ -45,7 +45,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers/li import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_empty.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_home.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; @@ -291,7 +291,7 @@ class _BrowserViewState extends ConsumerState }, ), ), - if (!hasTab) const Positioned.fill(child: TabsEmptyPlaceholder()), + if (!hasTab) const Positioned.fill(child: BrowserHome()), ], ), ); diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 32dd9e74..85bd1c4f 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -30,6 +30,9 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider +import mozilla.components.browser.session.storage.RecoverableBrowserState +import mozilla.components.browser.state.action.RestoreCompleteAction +import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.action.CustomTabListAction import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.concept.engine.selection.SelectionActionDelegate @@ -100,9 +103,31 @@ object GlobalComponents { } @DelicateCoroutinesApi - private fun restoreBrowserState(newComponents: Components) = + private fun restoreBrowserState( + newComponents: Components, + restoreTabsWithoutResumingSelection: Boolean, + ) = GlobalScope.launch(Dispatchers.Main) { - newComponents.useCases.tabsUseCases.restore(newComponents.core.sessionStorage) + if (restoreTabsWithoutResumingSelection) { + val restoredState = newComponents.core.sessionStorage.restore { true } + + if (restoredState != null) { + newComponents.useCases.tabsUseCases.restore( + state = RecoverableBrowserState( + tabs = restoredState.tabs, + // Intentionally do not resume the previously selected tab during the + // first full setup. We restore the tab list, but let startup open in + // its neutral/default state instead of jumping back into prior content. + selectedTabId = null, + ), + restoreLocation = TabListAction.RestoreAction.RestoreLocation.BEGINNING, + ) + } + + newComponents.core.store.dispatch(RestoreCompleteAction) + } else { + newComponents.useCases.tabsUseCases.restore(newComponents.core.sessionStorage) + } newComponents.core.sessionStorage.autoSave(newComponents.core.store) .periodicallyInForeground(interval = 30, unit = TimeUnit.SECONDS) @@ -198,7 +223,11 @@ object GlobalComponents { } } - val restoreJob = restoreBrowserState(newComponents) + val isFirstFullSetup = previousMode != ComponentsMode.FULL + val restoreJob = restoreBrowserState( + newComponents, + restoreTabsWithoutResumingSelection = isFirstFullSetup, + ) if (previousCustomTabs.isNotEmpty()) { restoreJob.invokeOnCompletion { GlobalScope.launch(Dispatchers.Main) {