turn empty tab screen into home screen

This commit is contained in:
Fabian Freund
2026-03-14 09:14:07 +01:00
parent 9db5a3e79b
commit 4f0dc91924
6 changed files with 204 additions and 54 deletions
@@ -286,6 +286,20 @@ class TabRepository extends _$TabRepository {
return false; return false;
} }
Future<bool> 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<bool> selectPreviousTab( Future<bool> selectPreviousTab(
String tabId, { String tabId, {
String? containerId, String? containerId,
@@ -1,28 +1,40 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.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/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/data/database/definitions.drift.dart';
import 'package:weblibre/features/quotes/domain/providers.dart'; import 'package:weblibre/features/quotes/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class TabsEmptyPlaceholder extends ConsumerWidget { class BrowserHome extends ConsumerWidget {
const TabsEmptyPlaceholder(); const BrowserHome({super.key});
static const _brandPurple = Color(0xFF9C83F8); static const _brandPurple = Color(0xFF9C83F8);
static const _brandYellow = Color(0xFFFBDC6B); static const _brandYellow = Color(0xFFFBDC6B);
static const _brandGrey = Color(0xFFA7A7A7); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final settings = ref.watch(generalSettingsWithDefaultsProvider); final settings = ref.watch(generalSettingsWithDefaultsProvider);
final quoteAsync = ref.watch(randomQuoteProvider); final quoteAsync = ref.watch(randomQuoteProvider);
final hasTabs = ref.watch(
tabListProvider.select((tabs) => tabs.value.isNotEmpty),
);
final viewportToolbarInsets = ref.watch( final viewportToolbarInsets = ref.watch(
browserViewportToolbarInsetsControllerProvider, browserViewportToolbarInsetsControllerProvider,
); );
@@ -38,6 +50,14 @@ class TabsEmptyPlaceholder extends ConsumerWidget {
).push(context); ).push(context);
} }
Future<void> viewTabs() {
return const TabViewRoute().push(context);
}
Future<void> resumeLatestTab() async {
await ref.read(tabRepositoryProvider.notifier).resumeLatestTab();
}
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -45,12 +65,15 @@ class TabsEmptyPlaceholder extends ConsumerWidget {
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
Color.alphaBlend( Color.alphaBlend(
_brandPurple.withValues(alpha: 0.06), _auraPurple.withValues(alpha: 0.38),
colorScheme.surfaceContainerLowest, colorScheme.surfaceContainerLowest,
), ),
colorScheme.surface,
Color.alphaBlend( Color.alphaBlend(
_brandYellow.withValues(alpha: 0.05), _auraShadow.withValues(alpha: 0.72),
colorScheme.surface,
),
Color.alphaBlend(
_auraGold.withValues(alpha: 0.34),
colorScheme.surfaceContainerHigh, colorScheme.surfaceContainerHigh,
), ),
], ],
@@ -59,25 +82,33 @@ class TabsEmptyPlaceholder extends ConsumerWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Align( const Positioned(
alignment: const Alignment(-0.9, -0.95), 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( child: _BackdropOrb(
size: 220, width: 320,
color: _brandPurple.withValues(alpha: 0.14), height: 320,
color: _auraShadowHighlight,
), ),
), ),
Align( Positioned.fill(
alignment: const Alignment(1.0, -0.35), child: IgnorePointer(
child: _BackdropOrb( child: ClipRect(
size: 180, child: BackdropFilter(
color: _brandYellow.withValues(alpha: 0.14), filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72),
), child: ColoredBox(color: _auraTint.withValues(alpha: 0.12)),
), ),
Align( ),
alignment: const Alignment(-0.75, 0.9),
child: _BackdropOrb(
size: 160,
color: _brandGrey.withValues(alpha: 0.12),
), ),
), ),
LayoutBuilder( LayoutBuilder(
@@ -148,23 +179,25 @@ class TabsEmptyPlaceholder extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( if (!hasTabs) ...[
'WebLibre is ready', Text(
textAlign: TextAlign.center, 'WebLibre is ready',
style: theme.textTheme.headlineSmall?.copyWith( textAlign: TextAlign.center,
fontWeight: FontWeight.w700, style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
), ),
), const SizedBox(height: 10),
const SizedBox(height: 10), Text(
Text( 'A browser that respects you. Open a tab and experience the web, libre.',
'A browser that respects you. Open a tab and experience the web, libre.', textAlign: TextAlign.center,
textAlign: TextAlign.center, style: theme.textTheme.bodyLarge?.copyWith(
style: theme.textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant,
color: colorScheme.onSurfaceVariant, height: 1.45,
height: 1.45, ),
), ),
), const SizedBox(height: 28),
const SizedBox(height: 28), ],
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@@ -238,10 +271,29 @@ class TabsEmptyPlaceholder extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.icon( Wrap(
onPressed: openNewTab, alignment: WrapAlignment.center,
icon: const Icon(Icons.add_rounded), spacing: 12,
label: const Text('Open new tab'), 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 { class _BackdropOrb extends StatelessWidget {
final double size; final double width;
final double height;
final Color color; final Color color;
const _BackdropOrb({required this.size, required this.color}); const _BackdropOrb({
required this.width,
required this.height,
required this.color,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IgnorePointer( return IgnorePointer(
child: Container( child: Container(
width: size, width: width,
height: size, height: height,
decoration: BoxDecoration(shape: BoxShape.circle, color: color), decoration: BoxDecoration(shape: BoxShape.circle, color: color),
), ),
); );
@@ -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/tab_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.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/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'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class CompactAppBarTitle extends ConsumerWidget { class CompactAppBarTitle extends ConsumerWidget {
@@ -41,13 +42,21 @@ class CompactAppBarTitle extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(selectedTabStateProvider); final tabState = ref.watch(selectedTabStateProvider);
final selectedTabType = ref.watch(selectedTabTypeProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id));
final showSiteSettingsBadge = ref.watch( final showSiteSettingsBadge = ref.watch(
showSiteSettingsBadgeProvider.select((value) => value.value == true), showSiteSettingsBadgeProvider.select((value) => value.value == true),
); );
if (tabState == null) { if (tabState == null) {
return const SizedBox.shrink(); return _EmptyAppBarAddressField(
onTap: () async {
await SearchRoute(
tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType,
).push(context);
},
);
} }
return CompactAppBarTitleView( return CompactAppBarTitleView(
@@ -178,13 +187,21 @@ class AppBarTitle extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(selectedTabStateProvider); final tabState = ref.watch(selectedTabStateProvider);
final selectedTabType = ref.watch(selectedTabTypeProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id));
final showSiteSettingsBadge = ref.watch( final showSiteSettingsBadge = ref.watch(
showSiteSettingsBadgeProvider.select((value) => value.value == true), showSiteSettingsBadgeProvider.select((value) => value.value == true),
); );
if (tabState == null) { if (tabState == null) {
return const SizedBox.shrink(); return _EmptyAppBarAddressField(
onTap: () async {
await SearchRoute(
tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType,
).push(context);
},
);
} }
return AppBarTitleView( 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) { String _searchTextForTab(TabState tabState) {
final searchText = tabState.url.scheme == 'about' final searchText = tabState.url.scheme == 'about'
? '' ? ''
@@ -114,7 +114,6 @@ class BrowserBottomAppBar extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Material( return Material(
elevation: 0,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
color: colorScheme.surfaceContainer, color: colorScheme.surfaceContainer,
@@ -211,8 +210,7 @@ class BrowserTabBar extends HookConsumerWidget {
final dragStartPosition = useRef(Offset.zero); final dragStartPosition = useRef(Offset.zero);
final showTabTitle = final showTabTitle = displayedSheet is! ViewTabsSheet;
selectedTabId != null && displayedSheet is! ViewTabsSheet;
final backgroundColor = final backgroundColor =
(settings.showContainerUi && (settings.showContainerUi &&
@@ -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/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/engine_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_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/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; 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/pwa/domain/providers.dart';
@@ -291,7 +291,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}, },
), ),
), ),
if (!hasTab) const Positioned.fill(child: TabsEmptyPlaceholder()), if (!hasTab) const Positioned.fill(child: BrowserHome()),
], ],
), ),
); );
@@ -30,6 +30,9 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider 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.action.CustomTabListAction
import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.concept.engine.selection.SelectionActionDelegate import mozilla.components.concept.engine.selection.SelectionActionDelegate
@@ -100,9 +103,31 @@ object GlobalComponents {
} }
@DelicateCoroutinesApi @DelicateCoroutinesApi
private fun restoreBrowserState(newComponents: Components) = private fun restoreBrowserState(
newComponents: Components,
restoreTabsWithoutResumingSelection: Boolean,
) =
GlobalScope.launch(Dispatchers.Main) { 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) newComponents.core.sessionStorage.autoSave(newComponents.core.store)
.periodicallyInForeground(interval = 30, unit = TimeUnit.SECONDS) .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()) { if (previousCustomTabs.isNotEmpty()) {
restoreJob.invokeOnCompletion { restoreJob.invokeOnCompletion {
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {