update onboarding; add missing safe areas

This commit is contained in:
Fabian Freund
2026-03-17 08:46:09 +01:00
parent 79b68f137e
commit 25f4d27f0d
49 changed files with 3938 additions and 2430 deletions
@@ -53,7 +53,8 @@ class BangCategoriesScreen extends HookConsumerWidget {
),
],
),
body: categoriesAsync.when(
body: SafeArea(
child: categoriesAsync.when(
skipLoadingOnReload: true,
data: (categories) {
return FadingScroll(
@@ -78,7 +79,9 @@ class BangCategoriesScreen extends HookConsumerWidget {
.map(
(category) => ExpansionPanel(
canTapOnHeader: true,
isExpanded: expanded.value.contains(category.key),
isExpanded: expanded.value.contains(
category.key,
),
headerBuilder: (context, isExpanded) =>
ListTile(title: Text(category.key)),
body: Padding(
@@ -118,6 +121,7 @@ class BangCategoriesScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -29,7 +29,8 @@ class BangMenuScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Bangs')),
body: ListView(
body: SafeArea(
child: ListView(
children: [
ListTile(
leading: const Icon(MdiIcons.accountAlert),
@@ -57,6 +58,7 @@ class BangMenuScreen extends HookConsumerWidget {
),
],
),
),
);
}
}
@@ -76,7 +76,8 @@ class BangSearchScreen extends HookConsumerWidget {
),
],
),
body: resultsAsync.when(
body: SafeArea(
child: resultsAsync.when(
skipLoadingOnReload: true,
data: (bangs) => FadingScroll(
fadingSize: 25,
@@ -101,6 +102,7 @@ class BangSearchScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'87d2a4a0bca93c2d925aa1c81ff80d443185950a';
String _$tabRepositoryHash() => r'ce4ac2f2efbb859ba66e98c6e6d21f8205d9b4ed';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -99,7 +99,8 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
),
],
),
body: Form(
body: SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
@@ -165,6 +166,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
),
),
),
),
);
}
}
@@ -1,6 +1,3 @@
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';
@@ -15,20 +12,11 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors
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';
import 'package:weblibre/presentation/widgets/browser_page.dart';
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);
@@ -80,79 +68,9 @@ class BrowserHome extends ConsumerWidget {
.resumeLatestContainerTab(containerId);
}
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
_auraPurple.withValues(alpha: 0.38),
colorScheme.surfaceContainerLowest,
),
Color.alphaBlend(
_auraShadow.withValues(alpha: 0.72),
colorScheme.surface,
),
Color.alphaBlend(
_auraGold.withValues(alpha: 0.34),
colorScheme.surfaceContainerHigh,
),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
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(
width: 320,
height: 320,
color: _auraShadowHighlight,
),
),
Positioned.fill(
child: IgnorePointer(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72),
child: ColoredBox(color: _auraTint.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),
return BrowserPage(
child: BrowserPageContent(
bottomViewportInset: bottomViewportInset,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
@@ -160,7 +78,7 @@ class BrowserHome extends ConsumerWidget {
if (containerData != null)
_ContainerHeader(container: containerData)
else
_BrandHeader(colorScheme: colorScheme),
BrandHeader(colorScheme: colorScheme),
const SizedBox(height: 24),
if (!hasTabs) ...[
Text(
@@ -207,13 +125,11 @@ class BrowserHome extends ConsumerWidget {
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainer.withValues(
alpha: 0.9,
),
color: colorScheme.surfaceContainer.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Color.alphaBlend(
_brandGrey.withValues(alpha: 0.18),
BrowserPage.brandGrey.withValues(alpha: 0.18),
colorScheme.outlineVariant,
),
),
@@ -227,22 +143,16 @@ class BrowserHome extends ConsumerWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHigh,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
MdiIcons.formatQuoteOpen,
size: 18,
),
child: const Icon(MdiIcons.formatQuoteOpen, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'A thought for the road',
style: theme.textTheme.titleMedium
?.copyWith(
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
@@ -258,9 +168,7 @@ class BrowserHome extends ConsumerWidget {
),
const SizedBox(height: 16),
switch (quoteAsync) {
AsyncData(:final value) => _QuoteBlock(
quote: value,
),
AsyncData(:final value) => _QuoteBlock(quote: value),
AsyncError() => Text(
'Open a new tab and make this space your own.',
style: theme.textTheme.bodyLarge?.copyWith(
@@ -268,9 +176,7 @@ class BrowserHome extends ConsumerWidget {
height: 1.5,
),
),
_ => const LinearProgressIndicator(
minHeight: 3,
),
_ => const LinearProgressIndicator(minHeight: 3),
},
],
),
@@ -309,58 +215,6 @@ 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),
),
);
}
}
@@ -467,26 +321,3 @@ class _QuoteBlock extends StatelessWidget {
);
}
}
class _BackdropOrb extends StatelessWidget {
final double width;
final double height;
final Color color;
const _BackdropOrb({
required this.width,
required this.height,
required this.color,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: width,
height: height,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
);
}
}
@@ -44,7 +44,8 @@ class ContainerListScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Containers')),
body: Skeletonizer(
body: SafeArea(
child: Skeletonizer(
enabled: containersAsync.isLoading,
child: containersAsync.when(
skipLoadingOnReload: true,
@@ -72,7 +73,9 @@ class ContainerListScreen extends HookConsumerWidget {
result ==
SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.read(
startProxyControllerProvider.notifier,
)
.shouldPromptProxyStart();
if (!context.mounted || !shouldStartProxy) {
@@ -173,6 +176,7 @@ class ContainerListScreen extends HookConsumerWidget {
),
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final newContainer = await ref
@@ -45,7 +45,8 @@ class ContainerSelectionScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Select Container')),
body: Skeletonizer(
body: SafeArea(
child: Skeletonizer(
enabled: containersAsync.isLoading,
child: containersAsync.when(
skipLoadingOnReload: true,
@@ -106,13 +107,17 @@ class ContainerSelectionScreen extends HookConsumerWidget {
loading: () => ListView.builder(
itemCount: 3,
itemBuilder: (context, index) => ContainerListTile(
ContainerData(id: Namespace.nil.value, color: Colors.transparent),
ContainerData(
id: Namespace.nil.value,
color: Colors.transparent,
),
isSelected: false,
onTap: null,
),
),
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final newContainer = await ref
@@ -0,0 +1,24 @@
/*
* 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/>.
*/
enum OnboardingMode {
express,
detailed,
}
@@ -0,0 +1,45 @@
/*
* 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 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/onboarding/domain/entities/onboarding_mode.dart';
part 'providers.g.dart';
@Riverpod()
class OnboardingModeNotifier extends _$OnboardingModeNotifier {
@override
OnboardingMode build() => OnboardingMode.detailed;
// ignore: use_setters_to_change_properties
void select(OnboardingMode mode) {
state = mode;
}
}
@Riverpod()
class EulaAcceptedNotifier extends _$EulaAcceptedNotifier {
@override
bool build() => false;
// ignore: use_setters_to_change_properties
void accept(bool value) {
state = value;
}
}
@@ -0,0 +1,116 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(OnboardingModeNotifier)
final onboardingModeProvider = OnboardingModeNotifierProvider._();
final class OnboardingModeNotifierProvider
extends $NotifierProvider<OnboardingModeNotifier, OnboardingMode> {
OnboardingModeNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'onboardingModeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$onboardingModeNotifierHash();
@$internal
@override
OnboardingModeNotifier create() => OnboardingModeNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(OnboardingMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<OnboardingMode>(value),
);
}
}
String _$onboardingModeNotifierHash() =>
r'bbb8e7ec1c85699566750d90c92c273e031d654b';
abstract class _$OnboardingModeNotifier extends $Notifier<OnboardingMode> {
OnboardingMode build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<OnboardingMode, OnboardingMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<OnboardingMode, OnboardingMode>,
OnboardingMode,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(EulaAcceptedNotifier)
final eulaAcceptedProvider = EulaAcceptedNotifierProvider._();
final class EulaAcceptedNotifierProvider
extends $NotifierProvider<EulaAcceptedNotifier, bool> {
EulaAcceptedNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'eulaAcceptedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$eulaAcceptedNotifierHash();
@$internal
@override
EulaAcceptedNotifier create() => EulaAcceptedNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$eulaAcceptedNotifierHash() =>
r'dafd708ac5cb586e8ca9cccc6d708598c5daab0e';
abstract class _$EulaAcceptedNotifier extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -17,6 +17,8 @@
* 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:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -25,11 +27,17 @@ import 'package:weblibre/core/providers/defaults.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/onboarding/domain/entities/onboarding_mode.dart';
import 'package:weblibre/features/onboarding/domain/providers.dart';
import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart';
import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart';
import 'package:weblibre/features/onboarding/presentation/pages/default_search.dart';
import 'package:weblibre/features/onboarding/presentation/pages/doh_settings.dart';
import 'package:weblibre/features/onboarding/presentation/pages/permissions.dart';
import 'package:weblibre/features/onboarding/presentation/pages/privacy_hardening.dart';
import 'package:weblibre/features/onboarding/presentation/pages/toolbar_layout.dart';
import 'package:weblibre/features/onboarding/presentation/pages/ublock_opt_in.dart';
import 'package:weblibre/features/onboarding/presentation/pages/welcome.dart';
import 'package:weblibre/features/user/domain/repositories/onboarding.dart';
@@ -50,6 +58,12 @@ class OnboardingScreen extends HookConsumerWidget {
final disableAnimations = MediaQuery.disableAnimationsOf(context);
final pageController = usePageController();
final eulaAccepted = ref.watch(eulaAcceptedProvider);
final onboardingMode = ref.watch(onboardingModeProvider);
final isReturningUser = currentRevision == 2;
final showDetailedPages =
isReturningUser || onboardingMode == OnboardingMode.detailed;
final pages = useMemoized<List<Widget>>(() {
switch (currentRevision) {
@@ -57,25 +71,45 @@ class OnboardingScreen extends HookConsumerWidget {
return [const AiConfigurationPage()];
default:
return [
const WelcomePage(),
WelcomePage(isReturningUser: isReturningUser),
const DefaultSearchPage(),
if (showDetailedPages) const DohSettingsPage(),
if (showDetailedPages) const ToolbarLayoutPage(),
const PrivacyHardeningPage(),
const AiConfigurationPage(),
if (showDetailedPages)
UBlockOptInPage(formKey: GlobalKey<FormState>()),
PermissionsPage(formKey: GlobalKey<FormState>()),
];
}
}, [currentRevision, targetRevision]);
}, [currentRevision, targetRevision, onboardingMode]);
final lastPage = useRef(pageController.initialPage);
final currentPage = useState(pageController.initialPage);
return Scaffold(
return PopScope(
canPop: currentPage.value == 0,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (disableAnimations) {
pageController.jumpToPage(currentPage.value - 1);
} else {
await pageController.previousPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
},
child: Scaffold(
body: SafeArea(
child: Column(
children: [
Expanded(
child: PageView(
controller: pageController,
physics: currentPage.value == 0 && !eulaAccepted
? const NeverScrollableScrollPhysics()
: null,
children: pages,
onPageChanged: (value) {
if (value > lastPage.value) {
@@ -116,8 +150,9 @@ class OnboardingScreen extends HookConsumerWidget {
controller: pageController,
count: pages.length,
effect: WormEffect(
dotColor: theme.colorScheme.onSurface,
activeDotColor: theme.colorScheme.primary,
dotHeight: 10.0,
dotWidth: 10.0,
),
),
),
@@ -125,9 +160,13 @@ class OnboardingScreen extends HookConsumerWidget {
if (currentPage.value < pages.length - 1)
Expanded(
child: TextButton.icon(
onPressed: () async {
onPressed: currentPage.value == 0 && !eulaAccepted
? null
: () async {
if (disableAnimations) {
pageController.jumpToPage(currentPage.value + 1);
pageController.jumpToPage(
currentPage.value + 1,
);
} else {
await pageController.nextPage(
duration: const Duration(milliseconds: 250),
@@ -149,6 +188,14 @@ class OnboardingScreen extends HookConsumerWidget {
formPage.formKey.currentState?.save();
}
if (onboardingMode == OnboardingMode.express) {
unawaited(
ref
.read(browserAddonServiceProvider.notifier)
.install('uBlock0@raymondhill.net'),
);
}
await ref
.read(onboardingRepositoryProvider.notifier)
.pushRevision(targetRevision);
@@ -175,6 +222,7 @@ class OnboardingScreen extends HookConsumerWidget {
],
),
),
),
);
}
}
@@ -23,6 +23,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class AiConfigurationPage extends HookConsumerWidget {
const AiConfigurationPage({super.key});
@@ -37,7 +38,10 @@ class AiConfigurationPage extends HookConsumerWidget {
),
);
return ListView(
return BrowserPage(
child: BrowserPageContent(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
@@ -45,6 +49,7 @@ class AiConfigurationPage extends HookConsumerWidget {
),
const SizedBox(height: 24),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('On Device AI'),
subtitle: const Text(
'Local on-device features including container topic and tab suggestions',
@@ -63,7 +68,7 @@ class AiConfigurationPage extends HookConsumerWidget {
Container(
width: double.infinity,
padding: const EdgeInsets.all(24.0),
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
margin: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(8.0),
@@ -102,6 +107,8 @@ class AiConfigurationPage extends HookConsumerWidget {
),
),
],
),
),
);
}
}
@@ -29,6 +29,7 @@ import 'package:weblibre/features/settings/presentation/controllers/save_setting
import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -61,12 +62,13 @@ class DefaultSearchPage extends HookConsumerWidget {
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
return BrowserPage(
child: BrowserPageContent(
child: bangs.when(
skipLoadingOnReload: true,
data: (availableBangs) {
return ListView(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
@@ -172,6 +174,7 @@ class DefaultSearchPage extends HookConsumerWidget {
),
loading: () => const SizedBox(height: 48, width: double.infinity),
),
),
);
}
}
@@ -0,0 +1,54 @@
/*
* 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 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/widgets/doh_settings_content.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class DohSettingsPage extends HookConsumerWidget {
const DohSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return BrowserPage(
child: BrowserPageContent(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
child: Text(
'DNS over HTTPS',
style: theme.textTheme.headlineMedium,
),
),
const SizedBox(height: 24),
const ListTileTheme(
contentPadding: EdgeInsets.zero,
child: DohSettingsContent(),
),
],
),
),
);
}
}
@@ -18,11 +18,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class PermissionsPage extends HookConsumerWidget implements IFormPage {
@override
@@ -38,30 +41,38 @@ class PermissionsPage extends HookConsumerWidget implements IFormPage {
() => Permission.notification.isGranted,
);
return Form(
final isDefaultBrowser = useCachedFuture(
() => GeckoBrowserService().isDefaultBrowser(),
);
final isCurrentDefaultBrowser = isDefaultBrowser.data == true;
return BrowserPage(
child: BrowserPageContent(
child: Form(
key: formKey,
child: ListView(
child: Column(
children: [
const SizedBox(height: 24),
Center(
child: Text('Permissions', style: theme.textTheme.headlineMedium),
child: Text(
'Permissions',
style: theme.textTheme.headlineMedium,
),
),
const SizedBox(height: 24),
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 16.0),
// child: const Text('WebLibre requires following permissions'),
// ),
// const SizedBox(height: 24),
Skeletonizer(
enabled: !notificationPermissionEnabled.hasData,
child: FormField(
initialValue: true,
onSaved: (newValue) async {
if (newValue == true) {
if (newValue == true &&
notificationPermissionEnabled.data != true) {
await Permission.notification.request();
}
},
builder: (field) => SwitchListTile(
contentPadding: EdgeInsets.zero,
value: field.value ?? true,
title: const Text('Notifications'),
subtitle: const Text(
@@ -75,8 +86,35 @@ class PermissionsPage extends HookConsumerWidget implements IFormPage {
),
),
),
Skeletonizer(
enabled: !isDefaultBrowser.hasData,
child: FormField(
key: ValueKey(isDefaultBrowser.data),
initialValue: isCurrentDefaultBrowser,
onSaved: (newValue) async {
if (newValue == true && isDefaultBrowser.data == false) {
await GeckoBrowserService().requestDefaultBrowser();
}
},
builder: (field) => SwitchListTile(
contentPadding: EdgeInsets.zero,
value: field.value ?? isCurrentDefaultBrowser,
title: const Text('Default Browser'),
subtitle: const Text(
'Set WebLibre as your default browser',
),
onChanged: isCurrentDefaultBrowser
? null
: (value) {
field.didChange(value);
},
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,334 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class PrivacyHardeningPage extends ConsumerWidget {
const PrivacyHardeningPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return BrowserPage(
child: BrowserPageContent(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
child: Text(
'Privacy & Hardening',
style: theme.textTheme.headlineMedium,
),
),
const SizedBox(height: 24),
const _LanguageFingerprintWarning(),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Browser Languages'),
subtitle: const Text(
'Configure language preferences exposed to websites',
),
leading: const Icon(Icons.translate),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await LocaleSettingsRoute().push(context);
},
),
const SizedBox(height: 16),
const _WebEngineHardeningToggle(),
const SizedBox(height: 16),
const _FingerprintProtectionToggle(),
const SizedBox(height: 16),
const _LocalNetworkAccessSection(),
],
),
),
);
}
}
class _LanguageFingerprintWarning extends ConsumerWidget {
const _LanguageFingerprintWarning();
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final configuredLocales = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.locales),
);
if (configuredLocales.length <= 1) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24.0),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
height: 1.4,
),
children: [
TextSpan(
text: 'Multiple Languages Detected\n\n',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
const TextSpan(text: ''),
TextSpan(
text:
'Your browser has ${configuredLocales.length} languages '
'configured (${configuredLocales.join(', ')}). '
'Websites can use your unique language combination to '
'fingerprint and track you across the web.\n\n',
),
const TextSpan(text: ''),
const TextSpan(
text:
'Consider reducing your browser languages to a single '
'language to minimize your fingerprint surface.',
),
],
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.onErrorContainer,
foregroundColor: theme.colorScheme.errorContainer,
),
onPressed: () async {
await LocaleSettingsRoute().push(context);
},
icon: const Icon(Icons.translate),
label: const Text('Review Languages'),
),
),
],
),
);
}
}
class _WebEngineHardeningToggle extends ConsumerWidget {
const _WebEngineHardeningToggle();
@override
Widget build(BuildContext context, WidgetRef ref) {
final preferenceGroups = ref.watch(
unifiedPreferenceSettingsRepositoryProvider(PreferencePartition.user),
);
final allGroupsActive = preferenceGroups.maybeWhen(
data: (data) =>
data.values.every((element) => element.isActiveOrOptional),
orElse: () => false,
);
return SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Complete Web Engine Hardening'),
subtitle: const Text(
'Apply all recommended security hardening preferences to the web engine',
),
secondary: const Icon(MdiIcons.shieldLock),
value: allGroupsActive,
onChanged: preferenceGroups.hasValue
? (value) async {
final notifier = ref.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
);
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
}
: null,
);
}
}
class _FingerprintProtectionToggle extends ConsumerWidget {
const _FingerprintProtectionToggle();
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final currentOverrides = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => s.fingerprintingProtectionOverrides,
),
);
final isHardened =
currentOverrides == FingerprintOverrides.hardenedDefaults().toString();
return Column(
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Hardened Fingerprint Protection'),
subtitle: const Text(
'Load comprehensive fingerprint protection defaults',
),
secondary: const Icon(MdiIcons.fingerprint),
value: isHardened,
onChanged: (value) async {
final overrides = value
? FingerprintOverrides.hardenedDefaults()
: FingerprintOverrides.defaults();
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.fingerprintingProtectionOverrides(overrides.toString()),
);
},
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
child: isHardened
? Container(
width: double.infinity,
padding: const EdgeInsets.all(24.0),
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius: BorderRadius.circular(8.0),
),
child: Text.rich(
TextSpan(
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onError,
height: 1.4,
),
children: [
TextSpan(
text: 'Compatibility Warning\n\n',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onError,
fontWeight: FontWeight.w600,
),
),
const TextSpan(text: ''),
const TextSpan(
text:
'Hardened fingerprint protection enables 60+ '
'protection targets including canvas '
'randomization, navigator spoofing, media device '
'masking, and more.\n\n',
),
const TextSpan(text: ''),
const TextSpan(
text:
'This may cause websites to break or behave '
'unexpectedly. You can fine-tune individual '
'targets in settings.',
),
],
),
),
)
: const SizedBox.shrink(),
),
],
);
}
}
class _LocalNetworkAccessSection extends ConsumerWidget {
const _LocalNetworkAccessSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final lnaBlocking = ref.watch(
engineSettingsWithDefaultsProvider.select((s) => s.lnaBlocking),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Local Network Protection'),
subtitle: Text(
'Websites can try to reach your device and other devices '
'on your home network, like routers, printers, or smart home '
'devices. By default, known trackers are automatically blocked '
'from doing this.',
),
leading: Icon(MdiIcons.lanDisconnect),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Block All Local Network Requests'),
subtitle: const Text(
'Ask for permission before any website accesses devices '
'on your home network, not just known trackers',
),
secondary: const Icon(MdiIcons.shieldLockOpen),
value: lnaBlocking ?? false,
onChanged: (value) async {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.lnaBlocking(value),
);
},
),
],
);
}
}
@@ -0,0 +1,76 @@
/*
* 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 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_preview.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class ToolbarLayoutPage extends HookConsumerWidget {
const ToolbarLayoutPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
return BrowserPage(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 32, 12, 16),
child: Center(
child: Text(
'Toolbar & Layout',
style: theme.textTheme.headlineMedium,
),
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: TabBarPreviewHeaderDelegate(
settings: settings,
backgroundColor: Colors.transparent,
compact: true,
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 32),
sliver: SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: const ListTileTheme(
contentPadding: EdgeInsets.zero,
child: ToolbarLayoutContent(),
),
),
),
),
),
],
),
);
}
}
@@ -25,6 +25,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class UBlockOptInPage extends HookConsumerWidget implements IFormPage {
@override
@@ -36,9 +37,11 @@ class UBlockOptInPage extends HookConsumerWidget implements IFormPage {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Form(
return BrowserPage(
child: BrowserPageContent(
child: Form(
key: formKey,
child: ListView(
child: Column(
children: [
const SizedBox(height: 24),
SvgPicture.asset('assets/images/ublock.svg'),
@@ -47,9 +50,7 @@ class UBlockOptInPage extends HookConsumerWidget implements IFormPage {
child: Text('uBlock Origin', style: theme.textTheme.headlineMedium),
),
const SizedBox(height: 24),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: MarkdownBody(
const MarkdownBody(
data: '''
uBlock Origin (uBO) is a CPU and memory-efficient **wide-spectrum content blocker** by **Raymond Hill** and available as a browser extensions for WebLibre.
@@ -58,7 +59,6 @@ It blocks ads, trackers, coin miners, popups, annoying anti-blockers, malware si
There are many other lists available to block even more.
''',
),
),
const SizedBox(height: 24),
FormField(
initialValue: true,
@@ -72,6 +72,7 @@ There are many other lists available to block even more.
}
},
builder: (field) => SwitchListTile(
contentPadding: EdgeInsets.zero,
value: field.value ?? false,
title: const Text('Install uBlock Origin Extension'),
onChanged: (value) {
@@ -81,6 +82,8 @@ There are many other lists available to block even more.
),
],
),
),
),
);
}
}
@@ -18,28 +18,390 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/onboarding/domain/entities/onboarding_mode.dart';
import 'package:weblibre/features/onboarding/domain/providers.dart';
import 'package:weblibre/presentation/widgets/browser_page.dart';
class WelcomePage extends StatelessWidget {
const WelcomePage({super.key});
class WelcomePage extends ConsumerWidget {
final bool isReturningUser;
const WelcomePage({super.key, this.isReturningUser = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final selectedMode = ref.watch(onboardingModeProvider);
final accepted = ref.watch(eulaAcceptedProvider);
return BrowserPage(
child: BrowserPageContent(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
BrandHeader(colorScheme: colorScheme),
const SizedBox(height: 24),
Text(
isReturningUser ? 'Welcome back!' : 'WebLibre is ready',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (isReturningUser) ...[
const SizedBox(height: 16),
_UpdateNotice(colorScheme: colorScheme, theme: theme),
],
if (!isReturningUser) ...[
const SizedBox(height: 40),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Choose your onboarding experience:',
style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 12),
_ModeOption(
mode: OnboardingMode.express,
title: 'Quick Start',
subtitle: 'Use recommended defaults and get browsing.',
icon: Icons.bolt,
selected: selectedMode == OnboardingMode.express,
onTap: () => ref
.read(onboardingModeProvider.notifier)
.select(OnboardingMode.express),
),
const SizedBox(height: 12),
_ModeOption(
mode: OnboardingMode.detailed,
title: 'Custom Setup',
subtitle: 'Configure DNS, toolbar, extensions, and more.',
icon: Icons.tune,
selected: selectedMode == OnboardingMode.detailed,
onTap: () => ref
.read(onboardingModeProvider.notifier)
.select(OnboardingMode.detailed),
),
],
const SizedBox(height: 40),
_EulaCheckbox(
accepted: accepted,
onAcceptedChanged: (value) =>
ref.read(eulaAcceptedProvider.notifier).accept(value),
),
],
),
),
);
}
}
class _UpdateNotice extends StatelessWidget {
final ColorScheme colorScheme;
final ThemeData theme;
const _UpdateNotice({required this.colorScheme, required this.theme});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
FractionallySizedBox(
widthFactor: 0.5,
child: SvgPicture.asset('assets/icon/icon.svg'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.primary.withValues(alpha: 0.3),
),
const SizedBox(height: 24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.auto_awesome,
color: BrowserPage.brandYellow,
size: 20,
),
const SizedBox(width: 8),
Text(
'The Privacy-Focused Browser',
softWrap: true,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
'A lot has changed!',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 12),
Text(
'This update includes significant changes that require '
'you to review your settings. Please walk through the '
'following pages to revalidate your configuration.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.extension,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Please re-check your extensions after this update '
'due to known migration issues.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Your existing settings will not be overridden '
'unless you explicitly change them during this setup.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
],
);
}
}
class _ModeOption extends StatelessWidget {
final OnboardingMode mode;
final String title;
final String subtitle;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _ModeOption({
required this.mode,
required this.title,
required this.subtitle,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: selected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected ? colorScheme.primary : Colors.transparent,
width: 1.5,
),
),
child: Row(
children: [
Icon(
icon,
color: selected
? BrowserPage.brandYellow
: colorScheme.onSurfaceVariant,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: selected
? colorScheme.onSurface
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: selected
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
),
],
),
),
if (selected) Icon(Icons.check_circle, color: colorScheme.primary),
],
),
),
);
}
}
class _EulaCheckbox extends StatelessWidget {
final bool accepted;
final ValueChanged<bool> onAcceptedChanged;
const _EulaCheckbox({
required this.accepted,
required this.onAcceptedChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return CheckboxListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: accepted,
controlAffinity: ListTileControlAffinity.leading,
onChanged: (value) => onAcceptedChanged(value ?? false),
title: Wrap(
children: [
Text(
'I have read and accept the ',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
GestureDetector(
onTap: () => _showLegalDocument(
context,
title: 'End User License Agreement',
assetPath: 'assets/legal/EULA.md',
),
child: Text(
'EULA',
style: theme.textTheme.bodySmall?.copyWith(
color: BrowserPage.brandYellow,
decoration: TextDecoration.underline,
decorationColor: BrowserPage.brandYellow,
),
),
),
Text(
' and ',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
GestureDetector(
onTap: () => _showLegalDocument(
context,
title: 'Privacy Policy',
assetPath: 'assets/legal/PRIVACY_POLICY.md',
),
child: Text(
'Privacy Policy',
style: theme.textTheme.bodySmall?.copyWith(
color: BrowserPage.brandYellow,
decoration: TextDecoration.underline,
decorationColor: BrowserPage.brandYellow,
),
),
),
Text(
'.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Future<void> _showLegalDocument(
BuildContext context, {
required String title,
required String assetPath,
}) async {
final content = await rootBundle.loadString(assetPath);
if (!context.mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
const Divider(height: 1),
Expanded(
child: Markdown(controller: scrollController, data: content),
),
],
),
),
);
}
}
@@ -33,7 +33,8 @@ class BangSettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Bang Settings')),
body: FadingScroll(
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
@@ -72,6 +73,7 @@ class BangSettingsScreen extends HookConsumerWidget {
);
},
),
),
);
}
}
@@ -18,155 +18,18 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/features/settings/presentation/widgets/doh_settings_content.dart';
class DohSettingsScreen extends HookConsumerWidget {
const DohSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final dohSettings = ref.watch(
engineSettingsWithDefaultsProvider.select((value) => value.dohSettings),
);
final customProviderController = useTextEditingController(
text: BuiltInDohProviders.isBuiltin(dohSettings.dohProviderUrl)
? null
: dohSettings.dohProviderUrl,
);
return Scaffold(
appBar: AppBar(title: const Text('DNS over HTTPS')),
body: SafeArea(
child: ListView(
children: [
const ListTile(
leading: Icon(MdiIcons.dns),
title: Text('Protection Level'),
subtitle: Text(
'Domain Name System (DNS) over HTTPS sends your request for a domain name through an encrypted connection, providing a secure DNS and making it harder for others to see which web site youre about to access.',
),
),
RadioGroup(
groupValue: dohSettings.dohSettingsMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.dohSettingsMode(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: DohSettingsMode.geckoDefault,
title: Text('Default Protection'),
subtitle: Text('DoH used only when default DNS fails'),
),
RadioListTile.adaptive(
value: DohSettingsMode.increased,
title: Text('Increased Protection'),
subtitle: Text('DoH preferred, default DNS as fallback'),
),
RadioListTile.adaptive(
value: DohSettingsMode.max,
title: Text('Max Protection'),
subtitle: Text('DoH only, no fallback'),
),
RadioListTile.adaptive(
value: DohSettingsMode.off,
title: Text('Off'),
subtitle: Text('Use your default DNS resolver'),
),
],
),
),
const ListTile(
leading: Icon(MdiIcons.routerNetwork),
title: Text('DoH Provider'),
),
RadioGroup(
groupValue: dohSettings.dohProviderUrl,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.dohProviderUrl(value),
);
}
},
child: Column(
children: BuiltInDohProviders.values
.map(
(provider) => RadioListTile.adaptive(
value: provider.url,
title: Text(provider.name),
subtitle: Text(provider.url),
),
)
.toList(),
),
),
RadioGroup(
groupValue: !BuiltInDohProviders.isBuiltin(
dohSettings.dohProviderUrl,
),
onChanged: (value) {},
child: RadioListTile(
value: true,
enabled: false,
title: Form(
key: formKey,
child: TextFormField(
controller: customProviderController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
label: Text('Custom Resolver URL'),
hintText: 'https://example.com/dns-query',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
return validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: false,
);
},
onSaved: (newProvider) async {
if (newProvider != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.dohProviderUrl(newProvider),
);
}
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() == true) {
formKey.currentState?.save();
}
},
),
),
),
),
],
),
body: const SafeArea(
child: SingleChildScrollView(child: DohSettingsContent()),
),
);
}
@@ -20,11 +20,15 @@
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
class GeneralSettingsScreen extends StatelessWidget {
const GeneralSettingsScreen({super.key});
@@ -40,7 +44,11 @@ class GeneralSettingsScreen extends StatelessWidget {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [_AppearanceSection(), _DownloadsSection()],
children: const [
_DefaultBrowserSection(),
_AppearanceSection(),
_DownloadsSection(),
],
);
},
),
@@ -49,6 +57,67 @@ class GeneralSettingsScreen extends StatelessWidget {
}
}
class _DefaultBrowserSection extends StatelessWidget {
const _DefaultBrowserSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Default Browser'),
_DefaultBrowserTile(),
],
);
}
}
class _DefaultBrowserTile extends HookConsumerWidget {
const _DefaultBrowserTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final defaultBrowserRefreshKey = useState(0);
useOnAppLifecycleStateChange((previous, current) {
if (current == AppLifecycleState.resumed) {
defaultBrowserRefreshKey.value++;
}
});
final isDefault = useCachedFuture(
() => GeckoBrowserService().isDefaultBrowser(),
[defaultBrowserRefreshKey.value],
);
final isCurrentDefaultBrowser = isDefault.data == true;
return CustomListTile(
title: 'Default Browser',
subtitle: isCurrentDefaultBrowser
? 'WebLibre is your default browser'
: 'Set WebLibre as your default browser',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
Icons.public,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: isCurrentDefaultBrowser
? null
: () async {
await GeckoBrowserService().requestDefaultBrowser();
defaultBrowserRefreshKey.value++;
},
icon: Icon(isCurrentDefaultBrowser ? Icons.check : Icons.open_in_new),
label: Text(isCurrentDefaultBrowser ? 'Default' : 'Set'),
),
);
}
}
class _AppearanceSection extends StatelessWidget {
const _AppearanceSection();
@@ -30,7 +30,8 @@ class SettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: FadingScroll(
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
@@ -50,6 +51,7 @@ class SettingsScreen extends HookConsumerWidget {
);
},
),
),
);
}
}
@@ -19,22 +19,9 @@
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.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/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_preview.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class ToolbarLayoutSettingsScreen extends HookConsumerWidget {
@@ -55,11 +42,14 @@ class ToolbarLayoutSettingsScreen extends HookConsumerWidget {
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _TabBarPreviewHeaderDelegate(settings: settings),
delegate: TabBarPreviewHeaderDelegate(
settings: settings,
compact: true,
),
),
const SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
sliver: SliverToBoxAdapter(child: _ToolbarLayoutContent()),
sliver: SliverToBoxAdapter(child: ToolbarLayoutContent()),
),
],
);
@@ -69,744 +59,3 @@ class ToolbarLayoutSettingsScreen extends HookConsumerWidget {
);
}
}
class _ToolbarLayoutContent extends StatelessWidget {
const _ToolbarLayoutContent();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Tab Bar'),
_TabBarPositionSection(),
_TabBarLayoutModeSection(),
_AutoHideTabBarTile(),
SettingSection(name: 'Contextual Toolbar'),
_ShowContextualTabBarTile(),
_CustomizeToolbarButtonsTile(),
SettingSection(name: 'Quick Tab Switcher'),
_ShowQuickTabSwitcherBarTile(),
_QuickTabSwitcherModeSection(),
_QuickTabSwitcherHistorySuggestionsTile(),
_QuickTabSwitcherShowTitlesTile(),
SettingSection(name: 'Tab View'),
_BottomSheetTabViewTile(),
_TabListShowFaviconsTile(),
],
);
}
}
class _TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
const _TabBarPreviewHeaderDelegate({required this.settings});
static const _kPreviewBaseHeight = 164.0;
final GeneralSettings settings;
double get _toolbarHeight {
var height = kToolbarHeight;
if (settings.tabBarShowContextualBar) {
height += BrowserTabBar.contextualToolabarHeight;
}
if (settings.tabBarShowQuickTabSwitcherBar) {
height += BrowserTabBar.quickTabSwitcherHeight;
}
return height;
}
@override
double get minExtent => _kPreviewBaseHeight + _toolbarHeight;
@override
double get maxExtent => _kPreviewBaseHeight + _toolbarHeight;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: _TabBarPreviewCard(settings: settings),
),
);
}
@override
bool shouldRebuild(covariant _TabBarPreviewHeaderDelegate oldDelegate) {
return oldDelegate.settings != settings;
}
}
class _TabBarPreviewCard extends HookWidget {
const _TabBarPreviewCard({required this.settings});
final GeneralSettings settings;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final quickTabsController = useScrollController();
final showMainToolbarActionButtons = !settings.tabBarShowContextualBar;
final previewTabState = TabState.$default('preview-tab').copyWith(
url: Uri.parse('https://weblibre.eu/docs'),
title: 'WebLibre Preview',
securityInfoState: SecurityState(
secure: true,
host: 'weblibre.eu',
issuer: 'WebLibre',
),
);
final previewQuickItems = <QuickTabSwitcherItem>[
QuickTabSwitcherItem(
id: 'regular-preview-tab',
isActive: true,
title: 'News',
tabMode: TabMode.regular,
isHistory: false,
isPinned:
settings.effectiveUiQuickTabSwitcherMode() ==
QuickTabSwitcherMode.containerTabs,
url: Uri.parse('https://example.com/news'),
color: settings.showContainerUi
? colorScheme.primary.withValues(alpha: 0.18)
: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
QuickTabSwitcherItem(
id: 'private-preview-tab',
isActive: false,
title: 'Private',
tabMode: TabMode.private,
isHistory: false,
isPinned: false,
url: Uri.parse('https://example.com/private'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
if (settings.showIsolatedTabUi)
QuickTabSwitcherItem(
id: 'isolated-preview-tab',
isActive: false,
title: 'Bank',
tabMode: TabMode.isolated('preview-isolated-context'),
isHistory: false,
isPinned: false,
url: Uri.parse('https://example.com/bank'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
if (settings.quickTabSwitcherShowHistorySuggestions)
QuickTabSwitcherItem(
id: 'history-preview-tab',
isActive: false,
title: 'Search',
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: Uri.parse('https://search.example.com'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
];
final tabCountButton = TabsCountButtonView(
isActive: false,
onTap: () {},
onLongPress: () {},
buttonBuilder: (isActive, onTap, onLongPress) {
return TabsActionButtonView(
isActive: isActive,
tabCountText: '5',
onTap: onTap,
onLongPress: onLongPress,
);
},
);
Widget buildQuickTabSwitcher() {
return QuickTabSwitcherView(
availableItems: previewQuickItems,
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
scrollController: quickTabsController,
showTitles: settings.quickTabSwitcherShowTitles,
showIsolatedTabUi: settings.showIsolatedTabUi,
onSelected: (_) async {},
itemWrapBuilder: (child, _) => child,
);
}
Widget buildContextualToolbar() {
return ContextualToolbarView(
buttons: [
NavigateBackButtonView(
canGoBack: true,
isLoading: false,
onPressed: () {},
onLongPress: () {},
),
NavigateForwardButtonView(
canGoForward: true,
onPressed: () {},
onLongPress: () {},
),
AddTabButtonView(onPressed: () {}, onLongPress: () {}),
tabCountButton,
NavigationMenuButtonView(onTap: () {}),
],
);
}
final mainToolbarActions = <Widget>[
if (showMainToolbarActionButtons) tabCountButton,
if (showMainToolbarActionButtons) NavigationMenuButtonView(onTap: () {}),
];
final bottomCombinedToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: true,
displayQuickTabSwitcher: true,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
actions: mainToolbarActions,
quickTabSwitcher: buildQuickTabSwitcher(),
contextualToolbar: buildContextualToolbar(),
);
final topMainToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: false,
showQuickTabSwitcherBar: false,
displayAppBar: true,
displayQuickTabSwitcher: false,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
actions: mainToolbarActions,
quickTabSwitcher: const SizedBox.shrink(),
contextualToolbar: const SizedBox.shrink(),
);
final topBottomToolbar = BrowserTabBarView(
showMainToolbar: false,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: false,
displayQuickTabSwitcher: true,
backgroundColor: colorScheme.surfaceContainer,
title: null,
actions: const [],
quickTabSwitcher: buildQuickTabSwitcher(),
contextualToolbar: buildContextualToolbar(),
);
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Live Preview'),
subtitle: Text(
'Reflects your current toolbar and layout settings',
),
leading: Icon(MdiIcons.televisionGuide),
contentPadding: EdgeInsets.symmetric(horizontal: 8.0),
),
Container(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
if (settings.tabBarPosition == TabBarPosition.top)
topMainToolbar,
Container(
height: 72,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
border: Border.symmetric(
horizontal: BorderSide(
color: colorScheme.outlineVariant,
),
),
),
child: Text(
'Page Content',
style: Theme.of(context).textTheme.labelMedium,
),
),
if (settings.tabBarPosition == TabBarPosition.top)
topBottomToolbar
else
bottomCombinedToolbar,
],
),
),
],
),
),
);
}
}
class _RegularPreviewTitle extends StatelessWidget {
const _RegularPreviewTitle({required this.tabState});
final TabState tabState;
@override
Widget build(BuildContext context) {
return AppBarTitleView(
tabState: tabState,
isTabTunneled: false,
showSiteSettingsBadge: false,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
);
}
}
class _CompactPreviewTitle extends StatelessWidget {
const _CompactPreviewTitle({required this.tabState});
final TabState tabState;
@override
Widget build(BuildContext context) {
return CompactAppBarTitleView(
tabState: tabState,
isTabTunneled: false,
showSiteSettingsBadge: false,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
);
}
}
void _noop() {}
class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
const _QuickTabSwitcherShowTitlesTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final quickTabSwitcherShowTitles = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Titles in Quick Tab Switcher'),
subtitle: const Text(
'Display tab titles alongside icons in the quick tab switcher bar',
),
secondary: const Icon(MdiIcons.textRecognition),
value: quickTabSwitcherShowTitles,
onChanged: tabBarShowQuickTabSwitcherBar
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowTitles(value),
);
}
: null,
);
}
}
class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
const _QuickTabSwitcherHistorySuggestionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showHistorySuggestions = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowHistorySuggestions,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('History Fallback in Quick Tab Switcher'),
subtitle: const Text(
'Use browsing history suggestions when no tab chips are available',
),
secondary: const Icon(MdiIcons.history),
value: showHistorySuggestions,
onChanged: tabBarShowQuickTabSwitcherBar
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowHistorySuggestions(value),
);
}
: null,
);
}
}
class _TabBarPositionSection extends HookConsumerWidget {
const _TabBarPositionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarPosition = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarPosition),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Tab Bar Position'),
leading: Icon(MdiIcons.dockWindow),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarPosition,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarPosition(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarPosition.top,
title: Text('Top'),
subtitle: Text('Persistent tab bar without auto-hide'),
),
RadioListTile.adaptive(
value: TabBarPosition.bottom,
title: Text('Bottom'),
subtitle: Text('Tab bar with auto-hide support'),
),
],
),
),
],
),
);
}
}
class _TabBarLayoutModeSection extends HookConsumerWidget {
const _TabBarLayoutModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarLayout = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarLayout),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Tab Bar Style'),
leading: Icon(MdiIcons.tabUnselected),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarLayout,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarLayout(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarLayout.withTitle,
title: Text('With Title'),
subtitle: Text('Shows page title and URL breadcrumb'),
),
RadioListTile.adaptive(
value: TabBarLayout.compact,
title: Text('Compact'),
subtitle: Text('Centered URL pill without page title'),
),
],
),
),
],
),
);
}
}
class _ShowContextualTabBarTile extends HookConsumerWidget {
const _ShowContextualTabBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowContextualBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowContextualBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Contextual Toolbar'),
subtitle: const Text(
'Show additional bottom toolbar for navigation and actions',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowContextualBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowContextualBar(value),
);
},
);
}
}
class _CustomizeToolbarButtonsTile extends HookConsumerWidget {
const _CustomizeToolbarButtonsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowContextualBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowContextualBar,
),
);
return ListTile(
leading: const Icon(Icons.tune),
title: const Text('Customize Toolbar Buttons'),
trailing: const Icon(Icons.chevron_right),
enabled: tabBarShowContextualBar,
onTap: () async {
await const ContextualToolbarSettingsRoute().push(context);
},
);
}
}
class _ShowQuickTabSwitcherBarTile extends HookConsumerWidget {
const _ShowQuickTabSwitcherBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Quick Tab Switcher Bar'),
subtitle: const Text(
'Show additional toolbar to quickly switch to recently used tabs',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowQuickTabSwitcherBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowQuickTabSwitcherBar(value),
);
},
);
}
}
class _QuickTabSwitcherModeSection extends HookConsumerWidget {
const _QuickTabSwitcherModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Quick Tab Switcher Mode'),
leading: Icon(MdiIcons.folderSettings),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: quickTabSwitcherMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.quickTabSwitcherMode(value),
);
}
},
child: Column(
children: [
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.lastUsedTabs,
title: Text('Recently Used Tabs'),
subtitle: Text('Recently used tabs across all containers'),
),
if (settings.showContainerUi)
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.containerTabs,
title: Text('Container Tabs'),
subtitle: Text('Ordered tabs of the selected container'),
),
],
),
),
],
),
);
}
}
class _AutoHideTabBarTile extends HookConsumerWidget {
const _AutoHideTabBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoHideTabBar = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.autoHideTabBar),
);
return SwitchListTile.adaptive(
title: const Text('Auto Hide Tab Bar'),
subtitle: const Text('Hide tab bar when scrolling'),
secondary: const Icon(MdiIcons.folderHidden),
value: autoHideTabBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.autoHideTabBar(value),
);
},
);
}
}
class _BottomSheetTabViewTile extends HookConsumerWidget {
const _BottomSheetTabViewTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabViewBottomSheet = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabViewBottomSheet),
);
return SwitchListTile.adaptive(
title: const Text('Bottom Sheet Tab View'),
subtitle: const Text(
'Display tabs in a bottom sheet instead of fullscreen',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabViewBottomSheet,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabViewBottomSheet(value),
);
},
);
}
}
class _TabListShowFaviconsTile extends HookConsumerWidget {
const _TabListShowFaviconsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabListShowFavicons = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
);
return SwitchListTile.adaptive(
title: const Text('Show Favicons in List View'),
subtitle: const Text(
'Display website icons instead of page thumbnails in tab list view',
),
secondary: const Icon(MdiIcons.web),
value: tabListShowFavicons,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabListShowFavicons(value),
);
},
);
}
}
@@ -69,7 +69,8 @@ class TrackingProtectionExceptionsScreen extends HookConsumerWidget {
),
],
),
body: exceptionsAsync.when(
body: SafeArea(
child: exceptionsAsync.when(
data: (exceptions) {
if (exceptions.isEmpty) {
return const _EmptyState();
@@ -89,6 +90,7 @@ class TrackingProtectionExceptionsScreen extends HookConsumerWidget {
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _ErrorState(error: error.toString()),
),
),
);
}
@@ -102,7 +102,8 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
),
],
),
body: preferenceGroups.when(
body: SafeArea(
child: preferenceGroups.when(
skipLoadingOnReload: true,
data: (data) {
return Column(
@@ -183,6 +184,7 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -43,7 +43,8 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: Text(groupName)),
body: settings.when(
body: SafeArea(
child: settings.when(
skipLoadingOnReload: true,
data: (group) {
return Column(
@@ -106,14 +107,17 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
Expanded(
child: SwitchListTile(
value: setting.value.isActive,
title: Text(setting.value.title ?? setting.key),
title: Text(
setting.value.title ?? setting.key,
),
subtitle: Text.rich(
TextSpan(
children: [
if (setting.value.requireUserOptIn)
WidgetSpan(
child: Container(
padding: const EdgeInsets.symmetric(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
@@ -156,9 +160,13 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
);
if (value) {
await notifier.apply(filter: [setting.key]);
await notifier.apply(
filter: [setting.key],
);
} else {
await notifier.reset(filter: [setting.key]);
await notifier.reset(
filter: [setting.key],
);
}
},
),
@@ -166,8 +174,11 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
else
Expanded(
child: ListTile(
title: Text(setting.value.title ?? setting.key),
subtitle: setting.value.description.mapNotNull(
title: Text(
setting.value.title ?? setting.key,
),
subtitle: setting.value.description
.mapNotNull(
(description) => Text(description),
),
leading: HardeningGroupIcon(
@@ -200,6 +211,7 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -0,0 +1,168 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/utils/form_validators.dart';
class DohSettingsContent extends HookConsumerWidget {
const DohSettingsContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final dohSettings = ref.watch(
engineSettingsWithDefaultsProvider.select((value) => value.dohSettings),
);
final customProviderController = useTextEditingController(
text: BuiltInDohProviders.isBuiltin(dohSettings.dohProviderUrl)
? null
: dohSettings.dohProviderUrl,
);
return Column(
children: [
const ListTile(
leading: Icon(MdiIcons.dns),
title: Text('Protection Level'),
subtitle: Text(
'Domain Name System (DNS) over HTTPS sends your request for a domain name through an encrypted connection, providing a secure DNS and making it harder for others to see which web site you\u2019re about to access.',
),
),
RadioGroup(
groupValue: dohSettings.dohSettingsMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.dohSettingsMode(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: DohSettingsMode.geckoDefault,
title: Text('Default Protection'),
subtitle: Text('DoH used only when default DNS fails'),
),
RadioListTile.adaptive(
value: DohSettingsMode.increased,
title: Text('Increased Protection'),
subtitle: Text('DoH preferred, default DNS as fallback'),
),
RadioListTile.adaptive(
value: DohSettingsMode.max,
title: Text('Max Protection'),
subtitle: Text('DoH only, no fallback'),
),
RadioListTile.adaptive(
value: DohSettingsMode.off,
title: Text('Off'),
subtitle: Text('Use your default DNS resolver'),
),
],
),
),
const ListTile(
leading: Icon(MdiIcons.routerNetwork),
title: Text('DoH Provider'),
),
RadioGroup(
groupValue: dohSettings.dohProviderUrl,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.dohProviderUrl(value),
);
}
},
child: Column(
children: BuiltInDohProviders.values
.map(
(provider) => RadioListTile.adaptive(
value: provider.url,
title: Text(provider.name),
subtitle: Text(provider.url),
),
)
.toList(),
),
),
RadioGroup(
groupValue: !BuiltInDohProviders.isBuiltin(
dohSettings.dohProviderUrl,
),
onChanged: (value) {},
child: RadioListTile(
value: true,
enabled: false,
title: Form(
key: formKey,
child: TextFormField(
controller: customProviderController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
label: Text('Custom Resolver URL'),
hintText: 'https://example.com/dns-query',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
return validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: false,
);
},
onSaved: (newProvider) async {
if (newProvider != null) {
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.dohProviderUrl(newProvider),
);
}
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() == true) {
formKey.currentState?.save();
}
},
),
),
),
),
],
);
}
}
@@ -0,0 +1,452 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class ToolbarLayoutContent extends StatelessWidget {
const ToolbarLayoutContent({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Tab Bar'),
_TabBarPositionSection(),
_TabBarLayoutModeSection(),
_AutoHideTabBarTile(),
SettingSection(name: 'Contextual Toolbar'),
_ShowContextualTabBarTile(),
_CustomizeToolbarButtonsTile(),
SettingSection(name: 'Quick Tab Switcher'),
_ShowQuickTabSwitcherBarTile(),
_QuickTabSwitcherModeSection(),
_QuickTabSwitcherHistorySuggestionsTile(),
_QuickTabSwitcherShowTitlesTile(),
SettingSection(name: 'Tab View'),
_BottomSheetTabViewTile(),
_TabListShowFaviconsTile(),
],
);
}
}
class _TabBarPositionSection extends HookConsumerWidget {
const _TabBarPositionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarPosition = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarPosition),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Tab Bar Position'),
leading: Icon(MdiIcons.dockWindow),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarPosition,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarPosition(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarPosition.top,
title: Text('Top'),
subtitle: Text('Persistent tab bar without auto-hide'),
),
RadioListTile.adaptive(
value: TabBarPosition.bottom,
title: Text('Bottom'),
subtitle: Text('Tab bar with auto-hide support'),
),
],
),
),
],
),
);
}
}
class _TabBarLayoutModeSection extends HookConsumerWidget {
const _TabBarLayoutModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarLayout = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarLayout),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Tab Bar Style'),
leading: Icon(MdiIcons.tabUnselected),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: tabBarLayout,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarLayout(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: TabBarLayout.withTitle,
title: Text('With Title'),
subtitle: Text('Shows page title and URL breadcrumb'),
),
RadioListTile.adaptive(
value: TabBarLayout.compact,
title: Text('Compact'),
subtitle: Text('Centered URL pill without page title'),
),
],
),
),
],
),
);
}
}
class _ShowContextualTabBarTile extends HookConsumerWidget {
const _ShowContextualTabBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowContextualBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowContextualBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Contextual Toolbar'),
subtitle: const Text(
'Show additional bottom toolbar for navigation and actions',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowContextualBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowContextualBar(value),
);
},
);
}
}
class _CustomizeToolbarButtonsTile extends HookConsumerWidget {
const _CustomizeToolbarButtonsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowContextualBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowContextualBar,
),
);
return ListTile(
leading: const Icon(Icons.tune),
title: const Text('Customize Toolbar Buttons'),
trailing: const Icon(Icons.chevron_right),
enabled: tabBarShowContextualBar,
onTap: () async {
await const ContextualToolbarSettingsRoute().push(context);
},
);
}
}
class _ShowQuickTabSwitcherBarTile extends HookConsumerWidget {
const _ShowQuickTabSwitcherBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Quick Tab Switcher Bar'),
subtitle: const Text(
'Show additional toolbar to quickly switch to recently used tabs',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowQuickTabSwitcherBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowQuickTabSwitcherBar(value),
);
},
);
}
}
class _QuickTabSwitcherModeSection extends HookConsumerWidget {
const _QuickTabSwitcherModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Quick Tab Switcher Mode'),
leading: Icon(MdiIcons.folderSettings),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: quickTabSwitcherMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.quickTabSwitcherMode(value),
);
}
},
child: Column(
children: [
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.lastUsedTabs,
title: Text('Recently Used Tabs'),
subtitle: Text('Recently used tabs across all containers'),
),
if (settings.showContainerUi)
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.containerTabs,
title: Text('Container Tabs'),
subtitle: Text('Ordered tabs of the selected container'),
),
],
),
),
],
),
);
}
}
class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
const _QuickTabSwitcherHistorySuggestionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showHistorySuggestions = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowHistorySuggestions,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('History Fallback in Quick Tab Switcher'),
subtitle: const Text(
'Use browsing history suggestions when no tab chips are available',
),
secondary: const Icon(MdiIcons.history),
value: showHistorySuggestions,
onChanged: tabBarShowQuickTabSwitcherBar
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowHistorySuggestions(value),
);
}
: null,
);
}
}
class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
const _QuickTabSwitcherShowTitlesTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final quickTabSwitcherShowTitles = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Titles in Quick Tab Switcher'),
subtitle: const Text(
'Display tab titles alongside icons in the quick tab switcher bar',
),
secondary: const Icon(MdiIcons.textRecognition),
value: quickTabSwitcherShowTitles,
onChanged: tabBarShowQuickTabSwitcherBar
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowTitles(value),
);
}
: null,
);
}
}
class _AutoHideTabBarTile extends HookConsumerWidget {
const _AutoHideTabBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoHideTabBar = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.autoHideTabBar),
);
return SwitchListTile.adaptive(
title: const Text('Auto Hide Tab Bar'),
subtitle: const Text('Hide tab bar when scrolling'),
secondary: const Icon(MdiIcons.folderHidden),
value: autoHideTabBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.autoHideTabBar(value),
);
},
);
}
}
class _BottomSheetTabViewTile extends HookConsumerWidget {
const _BottomSheetTabViewTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabViewBottomSheet = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabViewBottomSheet),
);
return SwitchListTile.adaptive(
title: const Text('Bottom Sheet Tab View'),
subtitle: const Text(
'Display tabs in a bottom sheet instead of fullscreen',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabViewBottomSheet,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabViewBottomSheet(value),
);
},
);
}
}
class _TabListShowFaviconsTile extends HookConsumerWidget {
const _TabListShowFaviconsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabListShowFavicons = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
);
return SwitchListTile.adaptive(
title: const Text('Show Favicons in List View'),
subtitle: const Text(
'Display website icons instead of page thumbnails in tab list view',
),
secondary: const Icon(MdiIcons.web),
value: tabListShowFavicons,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabListShowFavicons(value),
);
},
);
}
}
@@ -0,0 +1,380 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.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/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
const TabBarPreviewHeaderDelegate({
required this.settings,
this.backgroundColor,
this.compact = false,
this.padding = const EdgeInsets.symmetric(horizontal: 12.0),
});
static const _kPreviewBaseHeight = 90.0;
static const _kCompactPreviewBaseHeight = 42.0;
static const _kHeaderHeight = 72.0;
final GeneralSettings settings;
final Color? backgroundColor;
final bool compact;
final EdgeInsets padding;
double get _toolbarHeight {
var height = kToolbarHeight;
if (settings.tabBarShowContextualBar) {
height += BrowserTabBar.contextualToolabarHeight;
}
if (settings.tabBarShowQuickTabSwitcherBar) {
height += BrowserTabBar.quickTabSwitcherHeight;
}
return height;
}
double get _baseHeight =>
compact ? _kCompactPreviewBaseHeight : _kPreviewBaseHeight;
double get _headerHeight => compact ? 0.0 : _kHeaderHeight;
@override
double get minExtent =>
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
@override
double get maxExtent =>
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return ColoredBox(
color: backgroundColor ?? Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: padding,
child: TabBarPreviewCard(settings: settings, compact: compact),
),
);
}
@override
bool shouldRebuild(covariant TabBarPreviewHeaderDelegate oldDelegate) {
return oldDelegate.settings != settings ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.compact != compact;
}
}
class TabBarPreviewCard extends HookWidget {
const TabBarPreviewCard({
super.key,
required this.settings,
this.compact = false,
});
final GeneralSettings settings;
final bool compact;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final quickTabsController = useScrollController();
final showMainToolbarActionButtons = !settings.tabBarShowContextualBar;
final previewTabState = TabState.$default('preview-tab').copyWith(
url: Uri.parse('https://weblibre.eu/docs'),
title: 'WebLibre Preview',
securityInfoState: SecurityState(
secure: true,
host: 'weblibre.eu',
issuer: 'WebLibre',
),
);
final previewQuickItems = <QuickTabSwitcherItem>[
QuickTabSwitcherItem(
id: 'regular-preview-tab',
isActive: true,
title: 'News',
tabMode: TabMode.regular,
isHistory: false,
isPinned:
settings.effectiveUiQuickTabSwitcherMode() ==
QuickTabSwitcherMode.containerTabs,
url: Uri.parse('https://example.com/news'),
color: settings.showContainerUi
? colorScheme.primary.withValues(alpha: 0.18)
: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
QuickTabSwitcherItem(
id: 'private-preview-tab',
isActive: false,
title: 'Private',
tabMode: TabMode.private,
isHistory: false,
isPinned: false,
url: Uri.parse('https://example.com/private'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
if (settings.showIsolatedTabUi)
QuickTabSwitcherItem(
id: 'isolated-preview-tab',
isActive: false,
title: 'Bank',
tabMode: TabMode.isolated('preview-isolated-context'),
isHistory: false,
isPinned: false,
url: Uri.parse('https://example.com/bank'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
if (settings.quickTabSwitcherShowHistorySuggestions)
QuickTabSwitcherItem(
id: 'history-preview-tab',
isActive: false,
title: 'Search',
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: Uri.parse('https://search.example.com'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
),
];
final tabCountButton = TabsCountButtonView(
isActive: false,
onTap: () {},
onLongPress: () {},
buttonBuilder: (isActive, onTap, onLongPress) {
return TabsActionButtonView(
isActive: isActive,
tabCountText: '5',
onTap: onTap,
onLongPress: onLongPress,
);
},
);
Widget buildQuickTabSwitcher() {
return QuickTabSwitcherView(
availableItems: previewQuickItems,
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
scrollController: quickTabsController,
showTitles: settings.quickTabSwitcherShowTitles,
showIsolatedTabUi: settings.showIsolatedTabUi,
onSelected: (_) async {},
itemWrapBuilder: (child, _) => child,
);
}
Widget buildContextualToolbar() {
return ContextualToolbarView(
buttons: [
NavigateBackButtonView(
canGoBack: true,
isLoading: false,
onPressed: () {},
onLongPress: () {},
),
NavigateForwardButtonView(
canGoForward: true,
onPressed: () {},
onLongPress: () {},
),
AddTabButtonView(onPressed: () {}, onLongPress: () {}),
tabCountButton,
NavigationMenuButtonView(onTap: () {}),
],
);
}
final mainToolbarActions = <Widget>[
if (showMainToolbarActionButtons) tabCountButton,
if (showMainToolbarActionButtons) NavigationMenuButtonView(onTap: () {}),
];
final bottomCombinedToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: true,
displayQuickTabSwitcher: true,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
actions: mainToolbarActions,
quickTabSwitcher: buildQuickTabSwitcher(),
contextualToolbar: buildContextualToolbar(),
);
final topMainToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: false,
showQuickTabSwitcherBar: false,
displayAppBar: true,
displayQuickTabSwitcher: false,
backgroundColor: settings.showContainerUi
? colorScheme.primaryContainer.withValues(alpha: 0.55)
: colorScheme.surfaceContainer,
title: settings.tabBarLayout == TabBarLayout.compact
? _CompactPreviewTitle(tabState: previewTabState)
: _RegularPreviewTitle(tabState: previewTabState),
actions: mainToolbarActions,
quickTabSwitcher: const SizedBox.shrink(),
contextualToolbar: const SizedBox.shrink(),
);
final topBottomToolbar = BrowserTabBarView(
showMainToolbar: false,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
displayAppBar: false,
displayQuickTabSwitcher: true,
backgroundColor: colorScheme.surfaceContainer,
title: null,
actions: const [],
quickTabSwitcher: buildQuickTabSwitcher(),
contextualToolbar: buildContextualToolbar(),
);
final previewContent = Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: compact
? colorScheme.surface.withValues(alpha: 0.7)
: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar,
Container(
height: compact ? 40 : 72,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: compact
? colorScheme.surfaceContainerLowest.withValues(alpha: 0.7)
: colorScheme.surfaceContainerLowest,
border: Border.symmetric(
horizontal: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Text(
'Page Content',
style: Theme.of(context).textTheme.labelMedium,
),
),
if (settings.tabBarPosition == TabBarPosition.top)
topBottomToolbar
else
bottomCombinedToolbar,
],
),
);
if (compact) {
return previewContent;
}
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Live Preview'),
subtitle: Text(
'Reflects your current toolbar and layout settings',
),
leading: Icon(MdiIcons.televisionGuide),
contentPadding: EdgeInsets.symmetric(horizontal: 8.0),
),
previewContent,
],
),
),
);
}
}
class _RegularPreviewTitle extends StatelessWidget {
const _RegularPreviewTitle({required this.tabState});
final TabState tabState;
@override
Widget build(BuildContext context) {
return AppBarTitleView(
tabState: tabState,
isTabTunneled: false,
showSiteSettingsBadge: false,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
);
}
}
class _CompactPreviewTitle extends StatelessWidget {
const _CompactPreviewTitle({required this.tabState});
final TabState tabState;
@override
Widget build(BuildContext context) {
return CompactAppBarTitleView(
tabState: tabState,
isTabTunneled: false,
showSiteSettingsBadge: false,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
);
}
}
void _noop() {}
@@ -99,7 +99,8 @@ class CountryPickerScreen extends HookWidget {
),
),
),
body: ListView.builder(
body: SafeArea(
child: ListView.builder(
itemCount: filteredCountries.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
@@ -132,6 +133,7 @@ class CountryPickerScreen extends HookWidget {
);
},
),
),
);
}
}
@@ -239,7 +239,7 @@ class GeneralSettings with FastEquatable {
tabBarShowContextualBar = tabBarShowContextualBar ?? true,
tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true,
tabBarPosition = tabBarPosition ?? TabBarPosition.bottom,
tabBarLayout = tabBarLayout ?? TabBarLayout.withTitle,
tabBarLayout = tabBarLayout ?? TabBarLayout.compact,
quickTabSwitcherMode =
quickTabSwitcherMode ?? QuickTabSwitcherMode.lastUsedTabs,
pullToRefreshEnabled = pullToRefreshEnabled ?? true,
@@ -66,7 +66,8 @@ class ProfileBackupScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Create Backup')),
body: Padding(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Form(
key: formKey,
@@ -145,7 +146,10 @@ class ProfileBackupScreen extends HookConsumerWidget {
if (confirmation != passwordTextController.text) {
if (context.mounted) {
showErrorMessage(context, 'Passwords do not match');
showErrorMessage(
context,
'Passwords do not match',
);
}
return;
@@ -167,6 +171,7 @@ class ProfileBackupScreen extends HookConsumerWidget {
),
),
),
),
);
}
}
@@ -39,13 +39,16 @@ class ProfileBackupListScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Backups')),
body: backupListAsync.when(
body: SafeArea(
child: backupListAsync.when(
data: (backupList) {
return ListView.builder(
itemCount: backupList.length,
itemBuilder: (context, index) {
final file = backupList[index];
final match = _filenamePattern.firstMatch(p.basename(file.path));
final match = _filenamePattern.firstMatch(
p.basename(file.path),
);
if (match != null) {
final profileName = match.group(1)!;
@@ -91,6 +94,7 @@ class ProfileBackupListScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -124,7 +124,8 @@ class ProfileEditScreen extends HookConsumerWidget {
),
],
),
body: Form(
body: SafeArea(
child: Form(
key: formKey,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
@@ -145,8 +146,11 @@ class ProfileEditScreen extends HookConsumerWidget {
},
),
const SizedBox(height: 24),
if (profile != null) ...[_ProfileActionsSection(profile: profile!)],
if (profile != null) ...[
_ProfileActionsSection(profile: profile!),
],
],
),
),
),
);
@@ -46,13 +46,15 @@ class ProfileListScreen extends HookConsumerWidget {
),
],
),
body: usersAsync.when(
body: SafeArea(
child: usersAsync.when(
skipLoadingOnReload: true,
data: (profiles) => ListView.builder(
itemCount: profiles.length,
itemBuilder: (context, index) {
final profile = profiles[index];
final isSelected = filesystem.selectedProfile == profile.uuidValue;
final isSelected =
filesystem.selectedProfile == profile.uuidValue;
return ListTile(
enabled: !isSelected,
@@ -76,6 +78,7 @@ class ProfileListScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await CreateProfileRoute().push(context);
@@ -68,7 +68,8 @@ class ProfileRestoreScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Restore Backup')),
body: Padding(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Form(
key: formKey,
@@ -87,7 +88,10 @@ class ProfileRestoreScreen extends HookConsumerWidget {
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
return validateRequired(value, message: 'Password required');
return validateRequired(
value,
message: 'Password required',
);
},
),
const SizedBox(height: 16),
@@ -172,6 +176,7 @@ class ProfileRestoreScreen extends HookConsumerWidget {
),
),
),
),
);
}
}
@@ -59,7 +59,8 @@ class LockScreen extends HookConsumerWidget {
});
return Scaffold(
body: Center(
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -76,6 +77,7 @@ class LockScreen extends HookConsumerWidget {
],
),
),
),
);
}
}
@@ -24,7 +24,7 @@ part 'onboarding.g.dart';
@Riverpod(keepAlive: true)
class OnboardingRepository extends _$OnboardingRepository {
static const targetRevision = 2;
static const targetRevision = 3;
Future<int?> getCurrentRevision() {
return ref
@@ -43,7 +43,7 @@ class OnboardingRepository extends _$OnboardingRepository {
Future<bool> isOutdated() async {
final current = await getCurrentRevision();
return current != targetRevision;
return current == null || current < targetRevision;
}
@override
@@ -42,7 +42,7 @@ final class OnboardingRepositoryProvider
}
String _$onboardingRepositoryHash() =>
r'937a5fe08c09cc0475270d3707bfb6f087bcf123';
r'd16657963415392840cdba9f4bcc724963f6da63';
abstract class _$OnboardingRepository extends $Notifier<void> {
void build();
@@ -66,7 +66,8 @@ class FeedListScreen extends HookConsumerWidget {
),
],
),
body: feeds.when(
body: SafeArea(
child: feeds.when(
skipLoadingOnReload: true,
data: (feeds) {
return RefreshIndicator(
@@ -95,6 +96,7 @@ class FeedListScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
floatingActionButton: FloatingActionButton.extended(
label: const Text('Feed'),
icon: const Icon(Icons.add),
@@ -0,0 +1,197 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class BrowserPage extends ConsumerWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPage({super.key, this.bottomViewportInset = 0, required this.child});
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;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
auraPurple.withValues(alpha: 0.38),
colorScheme.surfaceContainerLowest,
),
Color.alphaBlend(
auraShadow.withValues(alpha: 0.72),
colorScheme.surface,
),
Color.alphaBlend(
auraGold.withValues(alpha: 0.34),
colorScheme.surfaceContainerHigh,
),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
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(
width: 320,
height: 320,
color: auraShadowHighlight,
),
),
Positioned.fill(
child: IgnorePointer(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72),
child: ColoredBox(color: auraTint.withValues(alpha: 0.12)),
),
),
),
),
Positioned.fill(child: child),
],
),
);
}
}
class BrowserPageContent extends StatelessWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPageContent({
super.key,
this.bottomViewportInset = 0,
required this.child,
});
@override
Widget build(BuildContext context) {
return 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: child,
),
),
),
);
},
);
}
}
class BrandHeader extends StatelessWidget {
final ColorScheme colorScheme;
const BrandHeader({super.key, 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(
BrowserPage.brandPurple.withValues(alpha: 0.18),
colorScheme.surfaceContainerHighest,
),
Color.alphaBlend(
BrowserPage.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 _BackdropOrb extends StatelessWidget {
final double width;
final double height;
final Color color;
const _BackdropOrb({
required this.width,
required this.height,
required this.color,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: width,
height: height,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
);
}
}
+1
View File
@@ -115,6 +115,7 @@ flutter:
- assets/images/
- assets/landing/
- assets/preferences/
- assets/legal/
- assets/quotes/
fonts:
- family: TorIcons
@@ -115,6 +115,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
companion object {
private const val TAG = "GeckoBrowserApiImpl"
private const val FRAGMENT_CONTAINER_ID = 0xBEEF
private const val REQUEST_CODE_BROWSER_ROLE = 1
private var isGeckoInitialized = false
}
@@ -424,4 +425,33 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
logger.debug("$TAG: Opened custom tab ${tab.id} for $url (private=$`private`)")
}
override fun isDefaultBrowser(): Boolean {
val currentActivity = requireNotNull(activity) { "Activity not attached" }
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
val roleManager = currentActivity.getSystemService(android.app.role.RoleManager::class.java)
return roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_BROWSER)
}
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("http://"))
val resolveInfo = currentActivity.packageManager.resolveActivity(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY)
return resolveInfo?.activityInfo?.packageName == currentActivity.packageName
}
override fun requestDefaultBrowser() {
val currentActivity = requireNotNull(activity) { "Activity not attached" }
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
val roleManager = currentActivity.getSystemService(android.app.role.RoleManager::class.java)
if (roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_BROWSER) &&
!roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_BROWSER)) {
currentActivity.startActivityForResult(
roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_BROWSER),
REQUEST_CODE_BROWSER_ROLE
)
return
}
}
val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
currentActivity.startActivity(intent)
}
}
@@ -4845,6 +4845,8 @@ interface GeckoBrowserApi {
fun showNativeFragment(): Boolean
fun onTrimMemory(level: Long)
fun openInCustomTab(url: String, private: Boolean, contextId: String?)
fun isDefaultBrowser(): Boolean
fun requestDefaultBrowser()
companion object {
/** The codec used by GeckoBrowserApi. */
@@ -4947,6 +4949,37 @@ interface GeckoBrowserApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.isDefaultBrowser$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isDefaultBrowser())
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.requestDefaultBrowser$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.requestDefaultBrowser()
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -56,4 +56,12 @@ class GeckoBrowserService {
contextId: contextId,
);
}
Future<bool> isDefaultBrowser() {
return _api.isDefaultBrowser();
}
Future<void> requestDefaultBrowser() {
return _api.requestDefaultBrowser();
}
}
@@ -5756,6 +5756,55 @@ class GeckoBrowserApi {
return;
}
}
Future<bool> isDefaultBrowser() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.isDefaultBrowser$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
Future<void> requestDefaultBrowser() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.requestDefaultBrowser$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}
class GeckoSyncApi {
@@ -1149,6 +1149,8 @@ abstract class GeckoBrowserApi {
required bool private,
required String? contextId,
});
bool isDefaultBrowser();
void requestDefaultBrowser();
}
enum SyncEngineValue { history, bookmarks, tabs }