prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,21 @@
/*
* 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'84cd9a7436fd4ac20972c25ed015c1c6b20f5a00';
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);
}
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.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';
class OnboardingScreen extends HookConsumerWidget {
final int currentRevision;
final int targetRevision;
const OnboardingScreen({
super.key,
required this.currentRevision,
required this.targetRevision,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
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) {
case 1:
return [const AiConfigurationPage()];
default:
return [
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, onboardingMode]);
final lastPage = useRef(pageController.initialPage);
final currentPage = useState(pageController.initialPage);
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) {
if (pages[lastPage.value] case final IFormPage formPage) {
formPage.formKey.currentState?.save();
}
}
lastPage.value = value;
currentPage.value = value;
},
),
),
Row(
children: [
Expanded(
child: Visibility(
visible: currentPage.value > 0,
child: TextButton.icon(
onPressed: () async {
if (disableAnimations) {
pageController.jumpToPage(currentPage.value - 1);
} else {
await pageController.previousPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
},
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
),
),
Expanded(
child: Center(
child: SmoothPageIndicator(
controller: pageController,
count: pages.length,
effect: WormEffect(
activeDotColor: theme.colorScheme.primary,
dotHeight: 10.0,
dotWidth: 10.0,
),
),
),
),
if (currentPage.value < pages.length - 1)
Expanded(
child: TextButton.icon(
onPressed: currentPage.value == 0 && !eulaAccepted
? null
: () async {
if (disableAnimations) {
pageController.jumpToPage(
currentPage.value + 1,
);
} else {
await pageController.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
},
iconAlignment: IconAlignment.end,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
)
else
Expanded(
child: TextButton.icon(
onPressed: () async {
if (pages[currentPage.value]
case final IFormPage formPage) {
formPage.formKey.currentState?.save();
}
if (onboardingMode == OnboardingMode.express) {
unawaited(
ref
.read(browserAddonServiceProvider.notifier)
.install('uBlock0@raymondhill.net'),
);
}
await ref
.read(onboardingRepositoryProvider.notifier)
.pushRevision(targetRevision);
ref.invalidate(routerProvider);
},
iconAlignment: IconAlignment.end,
icon: const Icon(Icons.done),
label: const Text('Done'),
),
),
],
),
],
),
),
),
);
}
}
@@ -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/>.
*/
import 'package:flutter/material.dart';
abstract class IFormPage {
GlobalKey<FormState> get formKey;
}
@@ -0,0 +1,115 @@
/*
* 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/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});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final enableLocalAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.enableLocalAiFeatures,
),
);
return BrowserPage(
child: BrowserPageContent(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
child: Text('AI Features', style: theme.textTheme.headlineMedium),
),
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',
),
secondary: const Icon(MdiIcons.creation),
value: enableLocalAiFeatures,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.enableLocalAiFeatures(value),
);
},
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(24.0),
margin: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(8.0),
),
child: Text.rich(
TextSpan(
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onError,
height: 1.4,
),
children: [
TextSpan(
text: 'Things to keep in mind\n\n',
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: Theme.of(context).colorScheme.onError,
fontWeight: FontWeight.w600,
),
),
const TextSpan(text: ''),
const TextSpan(
text:
'WebLibre uses a local AI model to analyze your open tab titles and suggest container tabs and names. All processing happens entirely on your device.\n\n',
),
const TextSpan(text: ''),
const TextSpan(
text:
'AI enhancements operate entirely within your browser, keeping all data on your device. Local AI processing respects your privacy and provides faster suggestions for container groups and names. You can control this behavior anytime through settings.\n\n',
),
const TextSpan(text: ''),
const TextSpan(
text:
'AI can sometimes make mistakes, so please review suggested group names and tab selections.',
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,182 @@
/*
* 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:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
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';
const defaultBangs = ['ddg', 'brave', 'startpage', 'qwant'];
class DefaultSearchPage extends HookConsumerWidget {
const DefaultSearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final bangs = ref.watch(bangListProvider(triggers: defaultBangs));
final activeBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.value),
);
final activeAutosuggest = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.defaultSearchSuggestionsProvider,
),
);
Future<void> updateSearchProvider(BangKey key) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.defaultSearchProvider(key),
);
}
return BrowserPage(
child: BrowserPageContent(
child: bangs.when(
skipLoadingOnReload: true,
data: (availableBangs) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Center(
child: Text('Search', style: theme.textTheme.headlineMedium),
),
const SizedBox(height: 24),
const ListTile(
title: Text('Default Search Provider'),
leading: Icon(MdiIcons.cloudSearch),
contentPadding: EdgeInsets.zero,
),
if (activeBang != null)
Align(
alignment: Alignment.centerLeft,
child: FilterChip(
showCheckmark: false,
label: Text(activeBang.websiteName),
avatar: UrlIcon([
activeBang.getDefaultUrl(),
], iconSize: 20),
selected: true,
onSelected: (value) {},
),
),
const Divider(),
Wrap(
spacing: 8.0,
children: [
...availableBangs.map(
(bang) => FilterChip(
showCheckmark: false,
label: Text(bang.websiteName),
avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20),
selected: activeBang?.trigger == bang.trigger,
onSelected: (selected) async {
if (selected) {
await updateSearchProvider(bang.toKey());
}
},
),
),
ActionChip(
label: const Text('Search more'),
avatar: const Icon(Icons.search),
onPressed: () async {
final trigger = await const BangSearchRoute()
.push<BangKey?>(context);
if (trigger != null) {
await updateSearchProvider(trigger);
}
},
),
],
),
const SizedBox(height: 24),
const ListTile(
title: Text('Default Autocomplete Provider'),
leading: Icon(MdiIcons.weatherCloudyArrowRight),
contentPadding: EdgeInsets.zero,
),
DropdownMenu<SearchSuggestionProviders>(
initialSelection: activeAutosuggest,
inputDecorationTheme: InputDecorationTheme(
prefixIconConstraints: BoxConstraints.tight(
const Size.square(24),
),
),
width: double.infinity,
leadingIcon: activeAutosuggest.relatedBang.mapNotNull(
(trigger) => BangIcon(trigger: trigger),
),
dropdownMenuEntries: SearchSuggestionProviders.values.map((
provider,
) {
return DropdownMenuEntry(
value: provider,
label: provider.label,
leadingIcon: provider.relatedBang.mapNotNull(
(trigger) => BangIcon(trigger: trigger),
),
);
}).toList(),
onSelected: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.defaultSearchSuggestionsProvider(value),
);
}
},
),
],
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Could not load search engines',
exception: error,
onRetry: () =>
ref.refresh(bangListProvider(triggers: defaultBangs)),
),
),
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(),
),
],
),
),
);
}
}
@@ -0,0 +1,120 @@
/*
* 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_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
final GlobalKey<FormState> formKey;
const PermissionsPage({super.key, required this.formKey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final notificationPermissionEnabled = useCachedFuture(
() => Permission.notification.isGranted,
);
final isDefaultBrowser = useCachedFuture(
() => GeckoBrowserService().isDefaultBrowser(),
);
final isCurrentDefaultBrowser = isDefaultBrowser.data == true;
return BrowserPage(
child: BrowserPageContent(
child: Form(
key: formKey,
child: Column(
children: [
const SizedBox(height: 24),
Center(
child: Text(
'Permissions',
style: theme.textTheme.headlineMedium,
),
),
const SizedBox(height: 24),
Skeletonizer(
enabled: !notificationPermissionEnabled.hasData,
child: FormField(
initialValue: true,
onSaved: (newValue) async {
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(
'Required to inform about download status',
),
onChanged: (notificationPermissionEnabled.data == true)
? null
: (value) {
field.didChange(value);
},
),
),
),
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,79 @@
/*
* 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(),
),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
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
final GlobalKey<FormState> formKey;
const UBlockOptInPage({super.key, required this.formKey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return BrowserPage(
child: BrowserPageContent(
child: Form(
key: formKey,
child: Column(
children: [
const SizedBox(height: 24),
SvgPicture.asset('assets/images/ublock.svg'),
const SizedBox(height: 8),
Center(
child: Text(
'uBlock Origin',
style: theme.textTheme.headlineMedium,
),
),
const SizedBox(height: 24),
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.
It blocks ads, trackers, coin miners, popups, annoying anti-blockers, malware sites, etc., by default using **EasyList, EasyPrivacy, Peter Lowe's Blocklist, Online Malicious URL Blocklist, and uBO filter lists**.
There are many other lists available to block even more.
''',
),
const SizedBox(height: 24),
FormField(
initialValue: true,
onSaved: (newValue) {
if (newValue == true) {
unawaited(
ref
.read(browserAddonServiceProvider.notifier)
.install('uBlock0@raymondhill.net'),
);
}
},
builder: (field) => SwitchListTile(
contentPadding: EdgeInsets.zero,
value: field.value ?? false,
title: const Text('Install uBlock Origin Extension'),
onChanged: (value) {
field.didChange(value);
},
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,408 @@
/*
* 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/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.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 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: 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),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.auto_awesome,
color: AppColors.of(context).brandLink,
size: 20,
),
const SizedBox(width: 8),
Text(
'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
? AppColors.of(context).brandLink
: 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: AppColors.of(context).brandLink,
decoration: TextDecoration.underline,
decorationColor: AppColors.of(context).brandLink,
),
),
),
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: AppColors.of(context).brandLink,
decoration: TextDecoration.underline,
decorationColor: AppColors.of(context).brandLink,
),
),
),
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),
),
],
),
),
);
}
}