This commit is contained in:
Fabian Freund
2026-03-13 19:14:39 +01:00
parent b9e97c4d41
commit 3262bb34b5
24 changed files with 8383 additions and 10 deletions
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'4dd31901b4d02ca075d3dcec3822f5a2832d164c';
String _$tabRepositoryHash() => r'22fdd97ad7f235590926915169c9e7230de94bdb';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'browser_viewport_toolbar_insets.g.dart';
@CopyWith()
class BrowserViewportToolbarInsetsState with FastEquatable {
final int dynamicToolbarMaxHeightPx;
final int verticalClippingPx;
BrowserViewportToolbarInsetsState({
this.dynamicToolbarMaxHeightPx = 0,
this.verticalClippingPx = 0,
});
static final zero = BrowserViewportToolbarInsetsState();
int get effectiveBottomInsetPx =>
math.max(0, dynamicToolbarMaxHeightPx + verticalClippingPx);
@override
List<Object?> get hashParameters => [
dynamicToolbarMaxHeightPx,
verticalClippingPx,
];
}
@Riverpod(keepAlive: true)
class BrowserViewportToolbarInsetsController
extends _$BrowserViewportToolbarInsetsController {
@override
BrowserViewportToolbarInsetsState build() {
return BrowserViewportToolbarInsetsState.zero;
}
void setDynamicToolbarMaxHeight(int heightPx) {
if (state.dynamicToolbarMaxHeightPx == heightPx) {
return;
}
state = state.copyWith(dynamicToolbarMaxHeightPx: heightPx);
}
void setVerticalClipping(int clippingPx) {
if (state.verticalClippingPx == clippingPx) {
return;
}
state = state.copyWith(verticalClippingPx: clippingPx);
}
void reset() {
if (state == BrowserViewportToolbarInsetsState.zero) {
return;
}
state = BrowserViewportToolbarInsetsState.zero;
}
}
@@ -0,0 +1,161 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browser_viewport_toolbar_insets.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$BrowserViewportToolbarInsetsStateCWProxy {
BrowserViewportToolbarInsetsState dynamicToolbarMaxHeightPx(
int dynamicToolbarMaxHeightPx,
);
BrowserViewportToolbarInsetsState verticalClippingPx(int verticalClippingPx);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BrowserViewportToolbarInsetsState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BrowserViewportToolbarInsetsState(...).copyWith(id: 12, name: "My name")
/// ```
BrowserViewportToolbarInsetsState call({
int dynamicToolbarMaxHeightPx,
int verticalClippingPx,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBrowserViewportToolbarInsetsState.copyWith(...)` or call `instanceOfBrowserViewportToolbarInsetsState.copyWith.fieldName(value)` for a single field.
class _$BrowserViewportToolbarInsetsStateCWProxyImpl
implements _$BrowserViewportToolbarInsetsStateCWProxy {
const _$BrowserViewportToolbarInsetsStateCWProxyImpl(this._value);
final BrowserViewportToolbarInsetsState _value;
@override
BrowserViewportToolbarInsetsState dynamicToolbarMaxHeightPx(
int dynamicToolbarMaxHeightPx,
) => call(dynamicToolbarMaxHeightPx: dynamicToolbarMaxHeightPx);
@override
BrowserViewportToolbarInsetsState verticalClippingPx(
int verticalClippingPx,
) => call(verticalClippingPx: verticalClippingPx);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BrowserViewportToolbarInsetsState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BrowserViewportToolbarInsetsState(...).copyWith(id: 12, name: "My name")
/// ```
BrowserViewportToolbarInsetsState call({
Object? dynamicToolbarMaxHeightPx = const $CopyWithPlaceholder(),
Object? verticalClippingPx = const $CopyWithPlaceholder(),
}) {
return BrowserViewportToolbarInsetsState(
dynamicToolbarMaxHeightPx:
dynamicToolbarMaxHeightPx == const $CopyWithPlaceholder() ||
dynamicToolbarMaxHeightPx == null
? _value.dynamicToolbarMaxHeightPx
// ignore: cast_nullable_to_non_nullable
: dynamicToolbarMaxHeightPx as int,
verticalClippingPx:
verticalClippingPx == const $CopyWithPlaceholder() ||
verticalClippingPx == null
? _value.verticalClippingPx
// ignore: cast_nullable_to_non_nullable
: verticalClippingPx as int,
);
}
}
extension $BrowserViewportToolbarInsetsStateCopyWith
on BrowserViewportToolbarInsetsState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBrowserViewportToolbarInsetsState.copyWith(...)` or `instanceOfBrowserViewportToolbarInsetsState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BrowserViewportToolbarInsetsStateCWProxy get copyWith =>
_$BrowserViewportToolbarInsetsStateCWProxyImpl(this);
}
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BrowserViewportToolbarInsetsController)
final browserViewportToolbarInsetsControllerProvider =
BrowserViewportToolbarInsetsControllerProvider._();
final class BrowserViewportToolbarInsetsControllerProvider
extends
$NotifierProvider<
BrowserViewportToolbarInsetsController,
BrowserViewportToolbarInsetsState
> {
BrowserViewportToolbarInsetsControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserViewportToolbarInsetsControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() =>
_$browserViewportToolbarInsetsControllerHash();
@$internal
@override
BrowserViewportToolbarInsetsController create() =>
BrowserViewportToolbarInsetsController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(BrowserViewportToolbarInsetsState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<BrowserViewportToolbarInsetsState>(
value,
),
);
}
}
String _$browserViewportToolbarInsetsControllerHash() =>
r'ae6444dc84383bef7d957e3bbd1c48d9ebba15ec';
abstract class _$BrowserViewportToolbarInsetsController
extends $Notifier<BrowserViewportToolbarInsetsState> {
BrowserViewportToolbarInsetsState build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
BrowserViewportToolbarInsetsState,
BrowserViewportToolbarInsetsState
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
BrowserViewportToolbarInsetsState,
BrowserViewportToolbarInsetsState
>,
BrowserViewportToolbarInsetsState,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -43,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da
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/providers/browser_viewport_toolbar_insets.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';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
@@ -428,9 +429,26 @@ class BrowserScreen extends HookConsumerWidget {
final lastToolbarMaxHeightPx = useRef<int?>(null);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(browserViewportToolbarInsetsControllerProvider.notifier)
.reset();
});
return null;
}, []);
useEffect(() {
if (lastToolbarMaxHeightPx.value != effectiveToolbarHeightPx) {
lastToolbarMaxHeightPx.value = effectiveToolbarHeightPx;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(browserViewportToolbarInsetsControllerProvider.notifier)
.setDynamicToolbarMaxHeight(effectiveToolbarHeightPx);
});
unawaited(
viewportService.setDynamicToolbarMaxHeight(effectiveToolbarHeightPx),
);
@@ -465,6 +483,13 @@ class BrowserScreen extends HookConsumerWidget {
if (targetClippingPx != lastClippingPx.value) {
lastClippingPx.value = targetClippingPx;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(browserViewportToolbarInsetsControllerProvider.notifier)
.setVerticalClipping(targetClippingPx);
});
unawaited(viewportService.setVerticalClipping(targetClippingPx));
}
}
@@ -45,6 +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/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';
@@ -108,8 +109,6 @@ class _BrowserViewState extends ConsumerState<BrowserView>
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
useOnInitialization(() async {
await ref
.read(generalSettingsRepositoryProvider.notifier)
@@ -292,12 +291,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
},
),
),
if (!hasTab)
Positioned.fill(
child: SizedBox.expand(
child: Container(color: colorScheme.surfaceContainerHighest),
),
),
if (!hasTab) const Positioned.fill(child: TabsEmptyPlaceholder()),
],
),
);
@@ -0,0 +1,328 @@
import 'dart:math' as math;
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/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();
static const _brandPurple = Color(0xFF9C83F8);
static const _brandYellow = Color(0xFFFBDC6B);
static const _brandGrey = Color(0xFFA7A7A7);
@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 viewportToolbarInsets = ref.watch(
browserViewportToolbarInsetsControllerProvider,
);
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
final bottomViewportInset =
viewportToolbarInsets.effectiveBottomInsetPx / pixelRatio;
Future<void> openNewTab() {
return SearchRoute(
tabType: settings.effectiveDefaultCreateTabType,
).push(context);
}
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
_brandPurple.withValues(alpha: 0.06),
colorScheme.surfaceContainerLowest,
),
colorScheme.surface,
Color.alphaBlend(
_brandYellow.withValues(alpha: 0.05),
colorScheme.surfaceContainerHigh,
),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: const Alignment(-0.9, -0.95),
child: _BackdropOrb(
size: 220,
color: _brandPurple.withValues(alpha: 0.14),
),
),
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),
),
),
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
24,
32,
24,
32 + bottomViewportInset,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight - 64 - bottomViewportInset,
),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
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,
),
),
),
const SizedBox(height: 24),
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: 28),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainer.withValues(
alpha: 0.9,
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Color.alphaBlend(
_brandGrey.withValues(alpha: 0.18),
colorScheme.outlineVariant,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
MdiIcons.formatQuoteOpen,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'A thought for the road',
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton.filledTonal(
tooltip: 'Refresh quote',
onPressed: () {
ref.invalidate(randomQuoteProvider);
},
icon: const Icon(Icons.refresh_rounded),
),
],
),
const SizedBox(height: 16),
switch (quoteAsync) {
AsyncData(:final value) => _QuoteBlock(
quote: value,
),
AsyncError() => Text(
'Open a new tab and make this space your own.',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
_ => const LinearProgressIndicator(
minHeight: 3,
),
},
],
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: openNewTab,
icon: const Icon(Icons.add_rounded),
label: const Text('Open new tab'),
),
],
),
),
),
),
);
},
),
],
),
);
}
}
class _QuoteBlock extends StatelessWidget {
final Quote? quote;
const _QuoteBlock({required this.quote});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (quote == null) {
return Text(
'Open a new tab and make this space your own.',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'"${quote!.quote}"',
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.55,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
'- ${quote!.author}',
style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
if (quote!.source case final String source when source.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
source,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
);
}
}
class _BackdropOrb extends StatelessWidget {
final double size;
final Color color;
const _BackdropOrb({required this.size, required this.color});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
);
}
}