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;
}
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(
String tabId, {
String? containerId,
@@ -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<void> viewTabs() {
return const TabViewRoute().push(context);
}
Future<void> 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),
),
);
@@ -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'
? ''
@@ -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 &&
@@ -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<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.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) {