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
@@ -33,44 +33,46 @@ class BangSettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Bang Settings')),
body: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
children: [
CustomListTile(
title: 'Bang Frequencies',
subtitle: 'Tracked usage for Bang recommendations',
suffix: FilledButton.icon(
onPressed: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequencies();
},
icon: const Icon(Icons.delete),
label: const Text('Clear'),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
children: [
CustomListTile(
title: 'Bang Frequencies',
subtitle: 'Tracked usage for Bang recommendations',
suffix: FilledButton.icon(
onPressed: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequencies();
},
icon: const Icon(Icons.delete),
label: const Text('Clear'),
),
),
),
const SettingSubSection(name: 'Repositories'),
const BangGroupListTile(
group: BangGroup.general,
title: 'General Bangs',
subtitle: 'Sync on demand from GitHub',
),
const BangGroupListTile(
group: BangGroup.assistant,
title: 'Assistant Bangs',
subtitle: 'Sync on-demand from GitHub',
),
const BangGroupListTile(
group: BangGroup.kagi,
title: 'Kagi Bangs',
subtitle: 'Sync on-demand from GitHub',
),
],
);
},
const SettingSubSection(name: 'Repositories'),
const BangGroupListTile(
group: BangGroup.general,
title: 'General Bangs',
subtitle: 'Sync on demand from GitHub',
),
const BangGroupListTile(
group: BangGroup.assistant,
title: 'Assistant Bangs',
subtitle: 'Sync on-demand from GitHub',
),
const BangGroupListTile(
group: BangGroup.kagi,
title: 'Kagi Bangs',
subtitle: 'Sync on-demand from GitHub',
),
],
);
},
),
),
);
}
@@ -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,25 +30,27 @@ class SettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_GeneralTile(),
_BrowsingTile(),
_ToolbarLayoutTile(),
_WebContentTile(),
_SearchTile(),
_PrivacySecurityTile(),
_ExtensionsTile(),
_SyncTile(),
_AdvancedTile(),
],
);
},
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_GeneralTile(),
_BrowsingTile(),
_ToolbarLayoutTile(),
_WebContentTile(),
_SearchTile(),
_PrivacySecurityTile(),
_ExtensionsTile(),
_SyncTile(),
_AdvancedTile(),
],
);
},
),
),
);
}
@@ -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,25 +69,27 @@ class TrackingProtectionExceptionsScreen extends HookConsumerWidget {
),
],
),
body: exceptionsAsync.when(
data: (exceptions) {
if (exceptions.isEmpty) {
return const _EmptyState();
}
body: SafeArea(
child: exceptionsAsync.when(
data: (exceptions) {
if (exceptions.isEmpty) {
return const _EmptyState();
}
return ListView.builder(
itemCount: exceptions.length,
itemBuilder: (context, index) {
final exception = exceptions[index];
return _ExceptionTile(
exception: exception,
onDelete: () => _deleteException(context, ref, exception),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _ErrorState(error: error.toString()),
return ListView.builder(
itemCount: exceptions.length,
itemBuilder: (context, index) {
final exception = exceptions[index];
return _ExceptionTile(
exception: exception,
onDelete: () => _deleteException(context, ref, exception),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _ErrorState(error: error.toString()),
),
),
);
}
@@ -102,86 +102,88 @@ class WebEngineHardeningScreen extends HookConsumerWidget {
),
],
),
body: preferenceGroups.when(
skipLoadingOnReload: true,
data: (data) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SwitchListTile(
value: allGroupsActive,
title: Text(
'Complete Hardening',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
body: SafeArea(
child: preferenceGroups.when(
skipLoadingOnReload: true,
data: (data) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SwitchListTile(
value: allGroupsActive,
title: Text(
'Complete Hardening',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
onChanged: (value) async {
final notifier = ref.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
);
onChanged: (value) async {
final notifier = ref.read(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
).notifier,
);
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
),
),
),
),
),
Expanded(
child: ListView(
children: data.entries.map((group) {
return Row(
children: [
Expanded(
child: ListTile(
title: Text(group.key),
subtitle: group.value.description.mapNotNull(
(description) => Text(description),
),
leading: Badge(
isLabelVisible: group.value.hasInactiveOptional,
child: HardeningGroupIcon(
isActive: group.value.isActiveOrOptional,
isPartlyActive: group.value.isPartlyActive,
Expanded(
child: ListView(
children: data.entries.map((group) {
return Row(
children: [
Expanded(
child: ListTile(
title: Text(group.key),
subtitle: group.value.description.mapNotNull(
(description) => Text(description),
),
leading: Badge(
isLabelVisible: group.value.hasInactiveOptional,
child: HardeningGroupIcon(
isActive: group.value.isActiveOrOptional,
isPartlyActive: group.value.isPartlyActive,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await WebEngineHardeningGroupRoute(
group: group.key,
).push(context);
},
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await WebEngineHardeningGroupRoute(
group: group.key,
).push(context);
},
),
),
],
);
}).toList(),
],
);
}).toList(),
),
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
unifiedPreferenceSettingsRepositoryProvider(
PreferencePartition.user,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
);
}
@@ -43,162 +43,174 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: Text(groupName)),
body: settings.when(
skipLoadingOnReload: true,
data: (group) {
return Column(
children: [
if (group.showMasterSwitch)
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SwitchListTile(
value: group.isActiveOrOptional,
title: Text(
groupName,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
subtitle: group.description.mapNotNull(
(description) => Text(
description,
body: SafeArea(
child: settings.when(
skipLoadingOnReload: true,
data: (group) {
return Column(
children: [
if (group.showMasterSwitch)
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SwitchListTile(
value: group.isActiveOrOptional,
title: Text(
groupName,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
subtitle: group.description.mapNotNull(
(description) => Text(
description,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
if (value) {
await notifier.apply();
} else {
await notifier.reset();
}
},
),
),
),
),
),
Expanded(
child: ListView(
children: group.settings.entries.map((setting) {
var value = setting.value.value.toString();
if (value.length > 160) {
value = '${value.substring(0, 160)}';
}
Expanded(
child: ListView(
children: group.settings.entries.map((setting) {
var value = setting.value.value.toString();
if (value.length > 160) {
value = '${value.substring(0, 160)}';
}
return Tooltip(
message: '${setting.key}: $value',
child: Row(
children: [
if (!setting.value.shouldBeDefault ||
!setting.value.isActive)
Expanded(
child: SwitchListTile(
value: setting.value.isActive,
title: Text(setting.value.title ?? setting.key),
subtitle: Text.rich(
TextSpan(
children: [
if (setting.value.requireUserOptIn)
WidgetSpan(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
margin: const EdgeInsets.only(
right: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'Optional',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color:
theme.colorScheme.onError,
return Tooltip(
message: '${setting.key}: $value',
child: Row(
children: [
if (!setting.value.shouldBeDefault ||
!setting.value.isActive)
Expanded(
child: SwitchListTile(
value: setting.value.isActive,
title: Text(
setting.value.title ?? setting.key,
),
subtitle: Text.rich(
TextSpan(
children: [
if (setting.value.requireUserOptIn)
WidgetSpan(
child: Container(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
margin: const EdgeInsets.only(
right: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'Optional',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color:
theme.colorScheme.onError,
),
),
),
),
),
if (setting.value.description != null)
TextSpan(
text: setting.value.description,
// style: theme.textTheme.bodyMedium,
),
],
if (setting.value.description != null)
TextSpan(
text: setting.value.description,
// style: theme.textTheme.bodyMedium,
),
],
),
),
secondary: HardeningGroupIcon(
isActive: setting.value.isActive,
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
if (value) {
await notifier.apply(
filter: [setting.key],
);
} else {
await notifier.reset(
filter: [setting.key],
);
}
},
),
)
else
Expanded(
child: ListTile(
title: Text(
setting.value.title ?? setting.key,
),
subtitle: setting.value.description
.mapNotNull(
(description) => Text(description),
),
leading: HardeningGroupIcon(
isActive: setting.value.isActive,
),
trailing: const Padding(
padding: EdgeInsets.only(right: 18.0),
child: Icon(Icons.check),
),
),
secondary: HardeningGroupIcon(
isActive: setting.value.isActive,
),
onChanged: (value) async {
final notifier = ref.read(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
).notifier,
);
if (value) {
await notifier.apply(filter: [setting.key]);
} else {
await notifier.reset(filter: [setting.key]);
}
},
),
)
else
Expanded(
child: ListTile(
title: Text(setting.value.title ?? setting.key),
subtitle: setting.value.description.mapNotNull(
(description) => Text(description),
),
leading: HardeningGroupIcon(
isActive: setting.value.isActive,
),
trailing: const Padding(
padding: EdgeInsets.only(right: 18.0),
child: Icon(Icons.check),
),
),
),
],
),
);
}).toList(),
],
),
);
}).toList(),
),
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
),
],
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load preference settings',
exception: error,
onRetry: () => ref.refresh(
preferenceSettingsGroupRepositoryProvider(
PreferencePartition.user,
groupName,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
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() {}