From 25f4d27f0d2ac0507797405c719355e159c6b36d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 17 Mar 2026 08:46:09 +0100 Subject: [PATCH] update onboarding; add missing safe areas --- .../presentation/screens/categories.dart | 122 +-- .../bangs/presentation/screens/menu.dart | 56 +- .../bangs/presentation/screens/search.dart | 48 +- .../geckoview/domain/repositories/tab.g.dart | 2 +- .../screens/bookmark_folder_edit.dart | 122 +-- .../presentation/widgets/browser_home.dart | 439 ++++------ .../presentation/screens/container_list.dart | 232 +++--- .../screens/container_selection.dart | 127 +-- .../domain/entities/onboarding_mode.dart | 24 + .../features/onboarding/domain/providers.dart | 45 ++ .../onboarding/domain/providers.g.dart | 116 +++ .../onboarding/presentation/onboarding.dart | 244 +++--- .../presentation/pages/ai_configuration.dart | 133 +-- .../presentation/pages/default_search.dart | 11 +- .../presentation/pages/doh_settings.dart | 54 ++ .../presentation/pages/permissions.dart | 108 ++- .../presentation/pages/privacy_hardening.dart | 334 ++++++++ .../presentation/pages/toolbar_layout.dart | 76 ++ .../presentation/pages/ublock_opt_in.dart | 79 +- .../presentation/pages/welcome.dart | 390 ++++++++- .../presentation/screens/bang_settings.dart | 76 +- .../presentation/screens/doh_settings.dart | 143 +--- .../screens/general_settings.dart | 71 +- .../presentation/screens/settings.dart | 40 +- .../screens/toolbar_layout_settings.dart | 765 +----------------- .../tracking_protection_exceptions.dart | 38 +- .../screens/web_engine_hardening.dart | 136 ++-- .../screens/web_engine_hardening_group.dart | 284 +++---- .../widgets/doh_settings_content.dart | 168 ++++ .../widgets/toolbar_layout_content.dart | 452 +++++++++++ .../presentation/widgets/toolbar_preview.dart | 380 +++++++++ .../presentation/screens/country_picker.dart | 60 +- .../user/data/models/general_settings.dart | 2 +- .../presentation/screens/profile_backup.dart | 193 ++--- .../screens/profile_backup_list.dart | 98 +-- .../presentation/screens/profile_edit.dart | 48 +- .../presentation/screens/profile_list.dart | 55 +- .../presentation/screens/profile_restore.dart | 189 ++--- .../presentation/widgets/auth_gate.dart | 32 +- .../user/domain/repositories/onboarding.dart | 4 +- .../domain/repositories/onboarding.g.dart | 2 +- .../presentation/screens/feed_list.dart | 50 +- .../presentation/widgets/browser_page.dart | 197 +++++ app/pubspec.yaml | 1 + .../api/GeckoBrowserApiImpl.kt | 30 + .../pigeons/Gecko.g.kt | 33 + .../src/domain/services/gecko_browser.dart | 8 + .../lib/src/pigeons/gecko.g.dart | 49 ++ .../pigeons/gecko.dart | 2 + 49 files changed, 3938 insertions(+), 2430 deletions(-) create mode 100644 app/lib/features/onboarding/domain/entities/onboarding_mode.dart create mode 100644 app/lib/features/onboarding/domain/providers.dart create mode 100644 app/lib/features/onboarding/domain/providers.g.dart create mode 100644 app/lib/features/onboarding/presentation/pages/doh_settings.dart create mode 100644 app/lib/features/onboarding/presentation/pages/privacy_hardening.dart create mode 100644 app/lib/features/onboarding/presentation/pages/toolbar_layout.dart create mode 100644 app/lib/features/settings/presentation/widgets/doh_settings_content.dart create mode 100644 app/lib/features/settings/presentation/widgets/toolbar_layout_content.dart create mode 100644 app/lib/features/settings/presentation/widgets/toolbar_preview.dart create mode 100644 app/lib/presentation/widgets/browser_page.dart diff --git a/app/lib/features/bangs/presentation/screens/categories.dart b/app/lib/features/bangs/presentation/screens/categories.dart index 43b1abe6..6213f7a8 100644 --- a/app/lib/features/bangs/presentation/screens/categories.dart +++ b/app/lib/features/bangs/presentation/screens/categories.dart @@ -53,70 +53,74 @@ class BangCategoriesScreen extends HookConsumerWidget { ), ], ), - body: categoriesAsync.when( - skipLoadingOnReload: true, - data: (categories) { - return FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return SingleChildScrollView( - controller: controller, - child: HookBuilder( - builder: (context) { - final expanded = useState({}); + body: SafeArea( + child: categoriesAsync.when( + skipLoadingOnReload: true, + data: (categories) { + return FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + child: HookBuilder( + builder: (context) { + final expanded = useState({}); - return ExpansionPanelList( - expansionCallback: (index, expand) { - final key = categories.keys.elementAt(index); - if (!expanded.value.contains(key)) { - expanded.value = {...expanded.value, key}; - } else { - expanded.value = {...expanded.value}..remove(key); - } - }, - children: categories.entries - .map( - (category) => ExpansionPanel( - canTapOnHeader: true, - isExpanded: expanded.value.contains(category.key), - headerBuilder: (context, isExpanded) => - ListTile(title: Text(category.key)), - body: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: category.value - .map( - (subCategory) => ListTile( - title: Text(subCategory), - onTap: () async { - await BangSubCategoryRoute( - category: category.key, - subCategory: subCategory, - ).push(context); - }, - ), - ) - .toList(), + return ExpansionPanelList( + expansionCallback: (index, expand) { + final key = categories.keys.elementAt(index); + if (!expanded.value.contains(key)) { + expanded.value = {...expanded.value, key}; + } else { + expanded.value = {...expanded.value}..remove(key); + } + }, + children: categories.entries + .map( + (category) => ExpansionPanel( + canTapOnHeader: true, + isExpanded: expanded.value.contains( + category.key, + ), + headerBuilder: (context, isExpanded) => + ListTile(title: Text(category.key)), + body: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: category.value + .map( + (subCategory) => ListTile( + title: Text(subCategory), + onTap: () async { + await BangSubCategoryRoute( + category: category.key, + subCategory: subCategory, + ).push(context); + }, + ), + ) + .toList(), + ), ), ), - ), - ) - .toList(), - ); - }, - ), - ); - }, - ); - }, - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load Bang Categories', - exception: error, + ) + .toList(), + ); + }, + ), + ); + }, + ); + }, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Bang Categories', + exception: error, + ), ), + loading: () => const Center(child: CircularProgressIndicator()), ), - loading: () => const Center(child: CircularProgressIndicator()), ), ); } diff --git a/app/lib/features/bangs/presentation/screens/menu.dart b/app/lib/features/bangs/presentation/screens/menu.dart index ff059449..2560cee4 100644 --- a/app/lib/features/bangs/presentation/screens/menu.dart +++ b/app/lib/features/bangs/presentation/screens/menu.dart @@ -29,33 +29,35 @@ class BangMenuScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar(title: const Text('Bangs')), - body: ListView( - children: [ - ListTile( - leading: const Icon(MdiIcons.accountAlert), - title: const Text('Manage User Bangs'), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await const UserBangsRoute().push(context); - }, - ), - ListTile( - leading: const Icon(Icons.search), - title: const Text('Search Bangs'), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await const BangSearchRoute().push(context); - }, - ), - ListTile( - leading: const Icon(MdiIcons.fileTree), - title: const Text('Browse Categories'), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await const BangCategoriesRoute().push(context); - }, - ), - ], + body: SafeArea( + child: ListView( + children: [ + ListTile( + leading: const Icon(MdiIcons.accountAlert), + title: const Text('Manage User Bangs'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await const UserBangsRoute().push(context); + }, + ), + ListTile( + leading: const Icon(Icons.search), + title: const Text('Search Bangs'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await const BangSearchRoute().push(context); + }, + ), + ListTile( + leading: const Icon(MdiIcons.fileTree), + title: const Text('Browse Categories'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await const BangCategoriesRoute().push(context); + }, + ), + ], + ), ), ); } diff --git a/app/lib/features/bangs/presentation/screens/search.dart b/app/lib/features/bangs/presentation/screens/search.dart index 17503e79..b4fc861f 100644 --- a/app/lib/features/bangs/presentation/screens/search.dart +++ b/app/lib/features/bangs/presentation/screens/search.dart @@ -76,30 +76,32 @@ class BangSearchScreen extends HookConsumerWidget { ), ], ), - body: resultsAsync.when( - skipLoadingOnReload: true, - data: (bangs) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: bangs.length, - itemBuilder: (context, index) { - final bang = bangs[index]; - return BangDetails( - bang, - onTap: () { - context.pop(bang.toKey()); - }, - ); - }, - ); - }, + body: SafeArea( + child: resultsAsync.when( + skipLoadingOnReload: true, + data: (bangs) => FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + itemCount: bangs.length, + itemBuilder: (context, index) { + final bang = bangs[index]; + return BangDetails( + bang, + onTap: () { + context.pop(bang.toKey()); + }, + ); + }, + ); + }, + ), + error: (error, stackTrace) => Center( + child: FailureWidget(title: 'Bang Search failed', exception: error), + ), + loading: () => const Center(child: CircularProgressIndicator()), ), - error: (error, stackTrace) => Center( - child: FailureWidget(title: 'Bang Search failed', exception: error), - ), - loading: () => const Center(child: CircularProgressIndicator()), ), ); } diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index 5e441220..a49796a1 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'87d2a4a0bca93c2d925aa1c81ff80d443185950a'; +String _$tabRepositoryHash() => r'ce4ac2f2efbb859ba66e98c6e6d21f8205d9b4ed'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart index 155c5394..28479adb 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart @@ -99,69 +99,71 @@ class BookmarkFolderEditScreen extends HookConsumerWidget { ), ], ), - body: Form( - key: formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: ListView( - children: [ - TextFormField( - controller: nameTextController, - decoration: const InputDecoration( - label: Text('Name'), - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - validator: validateRequired, - ), - const SizedBox(height: 16), - if (!isBookmarkRoot) ...[ - FolderTreePicker( - selectedFolderGuid: currentParentGuid, - excludeFolderGuids: folder != null - ? {folder!.guid} - : const {}, - entryGuid: BookmarkRoot.root.id, - ), - if (folder == null) ...[ - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Add to top'), - value: addToTop.value, - onChanged: (value) => addToTop.value = value, + body: SafeArea( + child: Form( + key: formKey, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: ListView( + children: [ + TextFormField( + controller: nameTextController, + decoration: const InputDecoration( + label: Text('Name'), + floatingLabelBehavior: FloatingLabelBehavior.always, ), - ], + validator: validateRequired, + ), const SizedBox(height: 16), - ], - if (folder != null) - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: BorderSide( - color: Theme.of(context).colorScheme.error, - ), - foregroundColor: Theme.of(context).colorScheme.error, - iconColor: Theme.of(context).colorScheme.error, - ), - label: const Text('Delete'), - icon: const Icon(Icons.delete), - onPressed: () async { - final result = await showDeleteFolderDialog(context); - - if (result == true) { - await ref - .read(bookmarksRepositoryProvider.notifier) - .delete(folder!.guid); - - if (context.mounted) { - context.pop(); - } - } - }, + if (!isBookmarkRoot) ...[ + FolderTreePicker( + selectedFolderGuid: currentParentGuid, + excludeFolderGuids: folder != null + ? {folder!.guid} + : const {}, + entryGuid: BookmarkRoot.root.id, ), - ), - ], + if (folder == null) ...[ + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Add to top'), + value: addToTop.value, + onChanged: (value) => addToTop.value = value, + ), + ], + const SizedBox(height: 16), + ], + if (folder != null) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + foregroundColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete), + onPressed: () async { + final result = await showDeleteFolderDialog(context); + + if (result == true) { + await ref + .read(bookmarksRepositoryProvider.notifier) + .delete(folder!.guid); + + if (context.mounted) { + context.pop(); + } + } + }, + ), + ), + ], + ), ), ), ), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart index b7a69e2f..179d98b1 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart @@ -1,6 +1,3 @@ -import 'dart:math' as math; -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_svg/svg.dart'; @@ -15,20 +12,11 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors import 'package:weblibre/features/quotes/data/database/definitions.drift.dart'; import 'package:weblibre/features/quotes/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/browser_page.dart'; class BrowserHome extends ConsumerWidget { const BrowserHome({super.key}); - static const _brandPurple = Color(0xFF9C83F8); - static const _brandYellow = Color(0xFFFBDC6B); - static const _brandGrey = Color(0xFFA7A7A7); - - static const _auraPurple = Color(0xFF2C2543); - static const _auraGold = Color(0xFF3C3827); - static const _auraShadow = Color(0xFF222224); - static const _auraShadowHighlight = Color(0xFF2D2D31); - static const _auraTint = Color(0xFF000000); - @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -80,286 +68,152 @@ class BrowserHome extends ConsumerWidget { .resumeLatestContainerTab(containerId); } - return DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color.alphaBlend( - _auraPurple.withValues(alpha: 0.38), - colorScheme.surfaceContainerLowest, - ), - Color.alphaBlend( - _auraShadow.withValues(alpha: 0.72), - colorScheme.surface, - ), - Color.alphaBlend( - _auraGold.withValues(alpha: 0.34), - colorScheme.surfaceContainerHigh, - ), - ], - ), - ), - child: Stack( - fit: StackFit.expand, + return BrowserPage( + child: BrowserPageContent( + bottomViewportInset: bottomViewportInset, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - const Positioned( - top: -70, - left: -120, - child: _BackdropOrb(width: 400, height: 400, color: _auraPurple), - ), - const Positioned( - top: 220, - right: -150, - child: _BackdropOrb(width: 340, height: 340, color: _auraGold), - ), - const Positioned( - bottom: 18, - left: -8, - child: _BackdropOrb( - width: 320, - height: 320, - color: _auraShadowHighlight, + if (containerData != null) + _ContainerHeader(container: containerData) + else + BrandHeader(colorScheme: colorScheme), + const SizedBox(height: 24), + if (!hasTabs) ...[ + Text( + 'WebLibre is ready', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - Positioned.fill( - child: IgnorePointer( - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72), - child: ColoredBox(color: _auraTint.withValues(alpha: 0.12)), + const SizedBox(height: 10), + Text( + 'A browser that respects you. Open a tab and experience the web, libre.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: 28), + ] else if (containerData != null) ...[ + Text( + containerData.name?.isNotEmpty == true + ? containerData.name! + : 'Container', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Text( + hasContainerTabs + ? 'No matching tab selected' + : 'No open tabs in this container', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: 28), + ], + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainer.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: Color.alphaBlend( + BrowserPage.brandGrey.withValues(alpha: 0.18), + colorScheme.outlineVariant, ), ), ), - ), - LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.fromLTRB( - 24, - 32, - 24, - 32 + bottomViewportInset, - ), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: math.max( - 0, - constraints.maxHeight - 64 - bottomViewportInset, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(MdiIcons.formatQuoteOpen, size: 18), ), - ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (containerData != null) - _ContainerHeader(container: containerData) - else - _BrandHeader(colorScheme: colorScheme), - const SizedBox(height: 24), - if (!hasTabs) ...[ - Text( - 'WebLibre is ready', - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - Text( - 'A browser that respects you. Open a tab and experience the web, libre.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.45, - ), - ), - const SizedBox(height: 28), - ] else if (containerData != null) ...[ - Text( - containerData.name?.isNotEmpty == true - ? containerData.name! - : 'Container', - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - Text( - hasContainerTabs - ? 'No matching tab selected' - : 'No open tabs in this container', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.45, - ), - ), - const SizedBox(height: 28), - ], - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.surfaceContainer.withValues( - alpha: 0.9, - ), - borderRadius: BorderRadius.circular(28), - border: Border.all( - color: Color.alphaBlend( - _brandGrey.withValues(alpha: 0.18), - colorScheme.outlineVariant, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: theme - .colorScheme - .surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - MdiIcons.formatQuoteOpen, - size: 18, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'A thought for the road', - style: theme.textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - IconButton.filledTonal( - tooltip: 'Refresh quote', - onPressed: () { - ref.invalidate(randomQuoteProvider); - }, - icon: const Icon(Icons.refresh_rounded), - ), - ], - ), - const SizedBox(height: 16), - switch (quoteAsync) { - AsyncData(:final value) => _QuoteBlock( - quote: value, - ), - AsyncError() => Text( - 'Open a new tab and make this space your own.', - style: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.5, - ), - ), - _ => const LinearProgressIndicator( - minHeight: 3, - ), - }, - ], - ), - ), - const SizedBox(height: 24), - Wrap( - alignment: WrapAlignment.center, - spacing: 12, - runSpacing: 12, - children: [ - if (hasTabs) - OutlinedButton.icon( - onPressed: viewTabs, - icon: const Icon(Icons.tab_rounded), - label: const Text('View tabs'), - ), - FilledButton.icon( - onPressed: openNewTab, - icon: const Icon(Icons.add_rounded), - label: const Text('New tab'), - ), - if (hasContainerTabs) - FilledButton.tonalIcon( - onPressed: resumeLatestContainerTab, - icon: const Icon(Icons.history_rounded), - label: const Text('Resume last tab'), - ) - else if (hasTabs && containerData == null) - FilledButton.tonalIcon( - onPressed: resumeLatestTab, - icon: const Icon(Icons.history_rounded), - label: const Text('Resume last tab'), - ), - ], - ), - ], + const SizedBox(width: 12), + Expanded( + child: Text( + 'A thought for the road', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), - ), + IconButton.filledTonal( + tooltip: 'Refresh quote', + onPressed: () { + ref.invalidate(randomQuoteProvider); + }, + icon: const Icon(Icons.refresh_rounded), + ), + ], ), - ); - }, + const SizedBox(height: 16), + switch (quoteAsync) { + AsyncData(:final value) => _QuoteBlock(quote: value), + AsyncError() => Text( + 'Open a new tab and make this space your own.', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + _ => const LinearProgressIndicator(minHeight: 3), + }, + ], + ), + ), + const SizedBox(height: 24), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + if (hasTabs) + OutlinedButton.icon( + onPressed: viewTabs, + icon: const Icon(Icons.tab_rounded), + label: const Text('View tabs'), + ), + FilledButton.icon( + onPressed: openNewTab, + icon: const Icon(Icons.add_rounded), + label: const Text('New tab'), + ), + if (hasContainerTabs) + FilledButton.tonalIcon( + onPressed: resumeLatestContainerTab, + icon: const Icon(Icons.history_rounded), + label: const Text('Resume last tab'), + ) + else if (hasTabs && containerData == null) + FilledButton.tonalIcon( + onPressed: resumeLatestTab, + icon: const Icon(Icons.history_rounded), + label: const Text('Resume last tab'), + ), + ], ), ], ), - ); - } -} - -class _BrandHeader extends StatelessWidget { - final ColorScheme colorScheme; - - const _BrandHeader({required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return Container( - width: 112, - height: 112, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color.alphaBlend( - BrowserHome._brandPurple.withValues(alpha: 0.18), - colorScheme.surfaceContainerHighest, - ), - Color.alphaBlend( - BrowserHome._brandYellow.withValues(alpha: 0.12), - colorScheme.surfaceContainer, - ), - ], - ), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.45), - ), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.08), - blurRadius: 32, - offset: const Offset(0, 18), - ), - ], - ), - child: Center( - child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72), ), ); } @@ -467,26 +321,3 @@ class _QuoteBlock extends StatelessWidget { ); } } - -class _BackdropOrb extends StatelessWidget { - final double width; - final double height; - final Color color; - - const _BackdropOrb({ - required this.width, - required this.height, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: Container( - width: width, - height: height, - decoration: BoxDecoration(shape: BoxShape.circle, color: color), - ), - ); - } -} diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart index 63cb44f7..369dddcf 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -44,131 +44,135 @@ class ContainerListScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Containers')), - body: Skeletonizer( - enabled: containersAsync.isLoading, - child: containersAsync.when( - skipLoadingOnReload: true, - data: (containers) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: containers.length, - itemBuilder: (context, index) { - final container = containers[index]; - return Slidable( - key: ValueKey(container.id), - startActionPane: ActionPane( - motion: const ScrollMotion(), - children: [ - if (container.id != selectedContainer) - SlidableAction( - onPressed: (context) async { - final result = await ref - .read(selectedContainerProvider.notifier) - .setContainerId(container.id); + body: SafeArea( + child: Skeletonizer( + enabled: containersAsync.isLoading, + child: containersAsync.when( + skipLoadingOnReload: true, + data: (containers) => FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + itemCount: containers.length, + itemBuilder: (context, index) { + final container = containers[index]; + return Slidable( + key: ValueKey(container.id), + startActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + if (container.id != selectedContainer) + SlidableAction( + onPressed: (context) async { + final result = await ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); - if (context.mounted && - result == - SetContainerResult.successHasProxy) { - final shouldStartProxy = await ref - .read(startProxyControllerProvider.notifier) - .shouldPromptProxyStart(); - - if (!context.mounted || !shouldStartProxy) { - return; - } - - final dialogResult = await showDialog( - context: context, - builder: (context) { - return const TorDialog(); - }, - ); - - if (dialogResult == true) { - await ref + if (context.mounted && + result == + SetContainerResult.successHasProxy) { + final shouldStartProxy = await ref .read( startProxyControllerProvider.notifier, ) - .startProxy(); + .shouldPromptProxyStart(); + + if (!context.mounted || !shouldStartProxy) { + return; + } + + final dialogResult = await showDialog( + context: context, + builder: (context) { + return const TorDialog(); + }, + ); + + if (dialogResult == true) { + await ref + .read( + startProxyControllerProvider.notifier, + ) + .startProxy(); + } } - } - }, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - icon: Icons.check, - label: 'Select', - ) - else + }, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + icon: Icons.check, + label: 'Select', + ) + else + SlidableAction( + onPressed: (context) { + ref + .read(selectedContainerProvider.notifier) + .clearContainer(); + }, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + icon: Icons.close, + label: 'Unselect', + ), + ], + ), + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ SlidableAction( - onPressed: (context) { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); + onPressed: (context) async { + await ref + .read(containerRepositoryProvider.notifier) + .deleteContainer(container.id); }, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, backgroundColor: Theme.of( context, - ).colorScheme.primaryContainer, - icon: Icons.close, - label: 'Unselect', + ).colorScheme.errorContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onErrorContainer, + icon: Icons.delete, + label: 'Delete', ), - ], - ), - endActionPane: ActionPane( - motion: const ScrollMotion(), - children: [ - SlidableAction( - onPressed: (context) async { - await ref - .read(containerRepositoryProvider.notifier) - .deleteContainer(container.id); - }, - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - foregroundColor: Theme.of( - context, - ).colorScheme.onErrorContainer, - icon: Icons.delete, - label: 'Delete', - ), - ], - ), - child: ContainerListTile( - container, - isSelected: container.id == selectedContainer, - onTap: () async { - await ContainerEditRoute( - containerData: jsonEncode(container.toJson()), - ).push(context); - }, - ), - ); - }, - ); - }, - ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load containers', - exception: error, - onRetry: () => ref.invalidate(watchContainersWithCountProvider), + ], + ), + child: ContainerListTile( + container, + isSelected: container.id == selectedContainer, + onTap: () async { + await ContainerEditRoute( + containerData: jsonEncode(container.toJson()), + ).push(context); + }, + ), + ); + }, + ); + }, ), - ), - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, index) => ContainerListTile( - ContainerData(id: 'null', color: Colors.transparent), - onTap: null, - isSelected: false, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load containers', + exception: error, + onRetry: () => ref.invalidate(watchContainersWithCountProvider), + ), + ), + loading: () => ListView.builder( + itemCount: 3, + itemBuilder: (context, index) => ContainerListTile( + ContainerData(id: 'null', color: Colors.transparent), + onTap: null, + isSelected: false, + ), ), ), ), diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart index 1b8c1e38..503ea39d 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart @@ -45,70 +45,75 @@ class ContainerSelectionScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Select Container')), - body: Skeletonizer( - enabled: containersAsync.isLoading, - child: containersAsync.when( - skipLoadingOnReload: true, - data: (containers) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: containers.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return ListTileTheme( - selectedColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - selectedTileColor: Theme.of( - context, - ).colorScheme.primaryContainer, - child: ListTile( - selected: selectedContainerId == null, - leading: CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(MdiIcons.folderHidden), + body: SafeArea( + child: Skeletonizer( + enabled: containersAsync.isLoading, + child: containersAsync.when( + skipLoadingOnReload: true, + data: (containers) => FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + itemCount: containers.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return ListTileTheme( + selectedColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + selectedTileColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: ListTile( + selected: selectedContainerId == null, + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(MdiIcons.folderHidden), + ), + title: const Text('Unassigned'), + onTap: () { + context.pop( + const ContainerSelectionResult.unassigned(), + ); + }, ), - title: const Text('Unassigned'), - onTap: () { - context.pop( - const ContainerSelectionResult.unassigned(), - ); - }, - ), - ); - } - - final container = containers[index - 1]; - return ContainerListTile( - container, - isSelected: container.id == selectedContainerId, - onTap: () { - context.pop( - ContainerSelectionResult.selected(container.id), ); - }, - ); - }, - ); - }, - ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load containers', - exception: error, - onRetry: () => ref.invalidate(watchContainersWithCountProvider), + } + + final container = containers[index - 1]; + return ContainerListTile( + container, + isSelected: container.id == selectedContainerId, + onTap: () { + context.pop( + ContainerSelectionResult.selected(container.id), + ); + }, + ); + }, + ); + }, ), - ), - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, index) => ContainerListTile( - ContainerData(id: Namespace.nil.value, color: Colors.transparent), - isSelected: false, - onTap: null, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load containers', + exception: error, + onRetry: () => ref.invalidate(watchContainersWithCountProvider), + ), + ), + loading: () => ListView.builder( + itemCount: 3, + itemBuilder: (context, index) => ContainerListTile( + ContainerData( + id: Namespace.nil.value, + color: Colors.transparent, + ), + isSelected: false, + onTap: null, + ), ), ), ), diff --git a/app/lib/features/onboarding/domain/entities/onboarding_mode.dart b/app/lib/features/onboarding/domain/entities/onboarding_mode.dart new file mode 100644 index 00000000..d5a51e01 --- /dev/null +++ b/app/lib/features/onboarding/domain/entities/onboarding_mode.dart @@ -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 . + */ + +enum OnboardingMode { + express, + detailed, +} diff --git a/app/lib/features/onboarding/domain/providers.dart b/app/lib/features/onboarding/domain/providers.dart new file mode 100644 index 00000000..4a92553d --- /dev/null +++ b/app/lib/features/onboarding/domain/providers.dart @@ -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 . + */ +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; + } +} diff --git a/app/lib/features/onboarding/domain/providers.g.dart b/app/lib/features/onboarding/domain/providers.g.dart new file mode 100644 index 00000000..ac153b12 --- /dev/null +++ b/app/lib/features/onboarding/domain/providers.g.dart @@ -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 { + 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(value), + ); + } +} + +String _$onboardingModeNotifierHash() => + r'bbb8e7ec1c85699566750d90c92c273e031d654b'; + +abstract class _$OnboardingModeNotifier extends $Notifier { + OnboardingMode build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + OnboardingMode, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(EulaAcceptedNotifier) +final eulaAcceptedProvider = EulaAcceptedNotifierProvider._(); + +final class EulaAcceptedNotifierProvider + extends $NotifierProvider { + 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(value), + ); + } +} + +String _$eulaAcceptedNotifierHash() => + r'dafd708ac5cb586e8ca9cccc6d708598c5daab0e'; + +abstract class _$EulaAcceptedNotifier extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/onboarding/presentation/onboarding.dart b/app/lib/features/onboarding/presentation/onboarding.dart index bfef2d6c..c44a1dba 100644 --- a/app/lib/features/onboarding/presentation/onboarding.dart +++ b/app/lib/features/onboarding/presentation/onboarding.dart @@ -17,6 +17,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,11 +27,17 @@ import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/onboarding/domain/entities/onboarding_mode.dart'; +import 'package:weblibre/features/onboarding/domain/providers.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart'; import 'package:weblibre/features/onboarding/presentation/pages/default_search.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/doh_settings.dart'; import 'package:weblibre/features/onboarding/presentation/pages/permissions.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/privacy_hardening.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/toolbar_layout.dart'; import 'package:weblibre/features/onboarding/presentation/pages/ublock_opt_in.dart'; import 'package:weblibre/features/onboarding/presentation/pages/welcome.dart'; import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; @@ -50,6 +58,12 @@ class OnboardingScreen extends HookConsumerWidget { final disableAnimations = MediaQuery.disableAnimationsOf(context); final pageController = usePageController(); + final eulaAccepted = ref.watch(eulaAcceptedProvider); + final onboardingMode = ref.watch(onboardingModeProvider); + + final isReturningUser = currentRevision == 2; + final showDetailedPages = + isReturningUser || onboardingMode == OnboardingMode.detailed; final pages = useMemoized>(() { switch (currentRevision) { @@ -57,122 +71,156 @@ class OnboardingScreen extends HookConsumerWidget { return [const AiConfigurationPage()]; default: return [ - const WelcomePage(), + WelcomePage(isReturningUser: isReturningUser), const DefaultSearchPage(), + if (showDetailedPages) const DohSettingsPage(), + if (showDetailedPages) const ToolbarLayoutPage(), + const PrivacyHardeningPage(), const AiConfigurationPage(), - UBlockOptInPage(formKey: GlobalKey()), + if (showDetailedPages) + UBlockOptInPage(formKey: GlobalKey()), PermissionsPage(formKey: GlobalKey()), ]; } - }, [currentRevision, targetRevision]); + }, [currentRevision, targetRevision, onboardingMode]); final lastPage = useRef(pageController.initialPage); final currentPage = useState(pageController.initialPage); - return Scaffold( - body: SafeArea( - child: Column( - children: [ - Expanded( - child: PageView( - controller: pageController, - children: pages, - onPageChanged: (value) { - if (value > lastPage.value) { - if (pages[lastPage.value] case final IFormPage formPage) { - formPage.formKey.currentState?.save(); + 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'), - ), - ), + lastPage.value = value; + currentPage.value = value; + }, ), - Expanded( - child: Center( - child: SmoothPageIndicator( - controller: pageController, - count: pages.length, - effect: WormEffect( - dotColor: theme.colorScheme.onSurface, - activeDotColor: theme.colorScheme.primary, + ), + 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'), ), ), ), - ), - if (currentPage.value < pages.length - 1) Expanded( - child: TextButton.icon( - onPressed: () 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(); - } - - await ref - .read(onboardingRepositoryProvider.notifier) - .pushRevision(targetRevision); - - await ref - .read(tabRepositoryProvider.notifier) - .addTab( - url: ref.read(docsUriProvider), - tabMode: TabMode.regular, - containerSelection: - const TabContainerSelection.unassigned(), - selectTab: true, - ); - - ref.invalidate(routerProvider); - }, - iconAlignment: IconAlignment.end, - icon: const Icon(Icons.done), - label: const Text('Done'), + 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); + + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: ref.read(docsUriProvider), + tabMode: TabMode.regular, + containerSelection: + const TabContainerSelection.unassigned(), + selectTab: true, + ); + + ref.invalidate(routerProvider); + }, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.done), + label: const Text('Done'), + ), + ), + ], + ), + ], + ), ), ), ); diff --git a/app/lib/features/onboarding/presentation/pages/ai_configuration.dart b/app/lib/features/onboarding/presentation/pages/ai_configuration.dart index 67748276..4b107a4a 100644 --- a/app/lib/features/onboarding/presentation/pages/ai_configuration.dart +++ b/app/lib/features/onboarding/presentation/pages/ai_configuration.dart @@ -23,6 +23,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/browser_page.dart'; class AiConfigurationPage extends HookConsumerWidget { const AiConfigurationPage({super.key}); @@ -37,71 +38,77 @@ class AiConfigurationPage extends HookConsumerWidget { ), ); - return ListView( - children: [ - const SizedBox(height: 24), - Center( - child: Text('AI Features', style: theme.textTheme.headlineMedium), - ), - const SizedBox(height: 24), - SwitchListTile.adaptive( - 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(horizontal: 24, 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.', - ), - ], + 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.', + ), + ], + ), + ), + ), + ], ), - ], + ), ); } } diff --git a/app/lib/features/onboarding/presentation/pages/default_search.dart b/app/lib/features/onboarding/presentation/pages/default_search.dart index 01a11ce0..2ead9a32 100644 --- a/app/lib/features/onboarding/presentation/pages/default_search.dart +++ b/app/lib/features/onboarding/presentation/pages/default_search.dart @@ -29,6 +29,7 @@ import 'package:weblibre/features/settings/presentation/controllers/save_setting import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/browser_page.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -61,12 +62,13 @@ class DefaultSearchPage extends HookConsumerWidget { ); } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: bangs.when( + return BrowserPage( + child: BrowserPageContent( + child: bangs.when( skipLoadingOnReload: true, data: (availableBangs) { - return ListView( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), Center( @@ -172,6 +174,7 @@ class DefaultSearchPage extends HookConsumerWidget { ), loading: () => const SizedBox(height: 48, width: double.infinity), ), + ), ); } } diff --git a/app/lib/features/onboarding/presentation/pages/doh_settings.dart b/app/lib/features/onboarding/presentation/pages/doh_settings.dart new file mode 100644 index 00000000..e04b8cb4 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/doh_settings.dart @@ -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 . + */ +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(), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/permissions.dart b/app/lib/features/onboarding/presentation/pages/permissions.dart index 4d3948f8..e7b3f57d 100644 --- a/app/lib/features/onboarding/presentation/pages/permissions.dart +++ b/app/lib/features/onboarding/presentation/pages/permissions.dart @@ -18,11 +18,14 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show GeckoBrowserService; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/presentation/widgets/browser_page.dart'; class PermissionsPage extends HookConsumerWidget implements IFormPage { @override @@ -38,44 +41,79 @@ class PermissionsPage extends HookConsumerWidget implements IFormPage { () => Permission.notification.isGranted, ); - return Form( - key: formKey, - child: ListView( - children: [ - const SizedBox(height: 24), - Center( - child: Text('Permissions', style: theme.textTheme.headlineMedium), - ), - const SizedBox(height: 24), - // Padding( - // padding: const EdgeInsets.symmetric(horizontal: 16.0), - // child: const Text('WebLibre requires following permissions'), - // ), - // const SizedBox(height: 24), - Skeletonizer( - enabled: !notificationPermissionEnabled.hasData, - child: FormField( - initialValue: true, - onSaved: (newValue) async { - if (newValue == true) { - await Permission.notification.request(); - } - }, - builder: (field) => SwitchListTile( - value: field.value ?? true, - title: const Text('Notifications'), - subtitle: const Text( - 'Required to inform about download status', + 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, ), - onChanged: (notificationPermissionEnabled.data == true) - ? null - : (value) { - field.didChange(value); - }, ), - ), + 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); + }, + ), + ), + ), + ], ), - ], + ), ), ); } diff --git a/app/lib/features/onboarding/presentation/pages/privacy_hardening.dart b/app/lib/features/onboarding/presentation/pages/privacy_hardening.dart new file mode 100644 index 00000000..4e53042e --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/privacy_hardening.dart @@ -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 . + */ +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), + ); + }, + ), + ], + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/toolbar_layout.dart b/app/lib/features/onboarding/presentation/pages/toolbar_layout.dart new file mode 100644 index 00000000..535e2ca8 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/toolbar_layout.dart @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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(), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart b/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart index 7d4ef509..5ff20668 100644 --- a/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart +++ b/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart @@ -25,6 +25,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; +import 'package:weblibre/presentation/widgets/browser_page.dart'; class UBlockOptInPage extends HookConsumerWidget implements IFormPage { @override @@ -36,50 +37,52 @@ class UBlockOptInPage extends HookConsumerWidget implements IFormPage { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - return Form( - key: formKey, - child: ListView( - 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 Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: MarkdownBody( - data: ''' + 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**. +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); + }, + ), + ), + ], ), - const SizedBox(height: 24), - FormField( - initialValue: true, - onSaved: (newValue) { - if (newValue == true) { - unawaited( - ref - .read(browserAddonServiceProvider.notifier) - .install('uBlock0@raymondhill.net'), - ); - } - }, - builder: (field) => SwitchListTile( - value: field.value ?? false, - title: const Text('Install uBlock Origin Extension'), - onChanged: (value) { - field.didChange(value); - }, - ), - ), - ], + ), ), ); } diff --git a/app/lib/features/onboarding/presentation/pages/welcome.dart b/app/lib/features/onboarding/presentation/pages/welcome.dart index 74bc5b99..5e5c471d 100644 --- a/app/lib/features/onboarding/presentation/pages/welcome.dart +++ b/app/lib/features/onboarding/presentation/pages/welcome.dart @@ -18,28 +18,390 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/onboarding/domain/entities/onboarding_mode.dart'; +import 'package:weblibre/features/onboarding/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/browser_page.dart'; -class WelcomePage extends StatelessWidget { - const WelcomePage({super.key}); +class WelcomePage extends ConsumerWidget { + final bool isReturningUser; + + const WelcomePage({super.key, this.isReturningUser = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final selectedMode = ref.watch(onboardingModeProvider); + final accepted = ref.watch(eulaAcceptedProvider); + + return BrowserPage( + child: BrowserPageContent( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + BrandHeader(colorScheme: colorScheme), + const SizedBox(height: 24), + Text( + isReturningUser ? 'Welcome back!' : 'WebLibre is ready', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (isReturningUser) ...[ + const SizedBox(height: 16), + _UpdateNotice(colorScheme: colorScheme, theme: theme), + ], + if (!isReturningUser) ...[ + const SizedBox(height: 40), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Choose your onboarding experience:', + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + _ModeOption( + mode: OnboardingMode.express, + title: 'Quick Start', + subtitle: 'Use recommended defaults and get browsing.', + icon: Icons.bolt, + selected: selectedMode == OnboardingMode.express, + onTap: () => ref + .read(onboardingModeProvider.notifier) + .select(OnboardingMode.express), + ), + const SizedBox(height: 12), + _ModeOption( + mode: OnboardingMode.detailed, + title: 'Custom Setup', + subtitle: 'Configure DNS, toolbar, extensions, and more.', + icon: Icons.tune, + selected: selectedMode == OnboardingMode.detailed, + onTap: () => ref + .read(onboardingModeProvider.notifier) + .select(OnboardingMode.detailed), + ), + ], + const SizedBox(height: 40), + _EulaCheckbox( + accepted: accepted, + onAcceptedChanged: (value) => + ref.read(eulaAcceptedProvider.notifier).accept(value), + ), + ], + ), + ), + ); + } +} + +class _UpdateNotice extends StatelessWidget { + final ColorScheme colorScheme; + final ThemeData theme; + + const _UpdateNotice({required this.colorScheme, required this.theme}); @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 24), - FractionallySizedBox( - widthFactor: 0.5, - child: SvgPicture.asset('assets/icon/icon.svg'), - ), - const SizedBox(height: 24), - Text( - 'The Privacy-Focused Browser', - softWrap: true, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium, + 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: [ + const Icon( + Icons.auto_awesome, + color: BrowserPage.brandYellow, + 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 + ? BrowserPage.brandYellow + : colorScheme.onSurfaceVariant, + size: 28, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: selected + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + color: selected + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + ), + ], + ), + ), + if (selected) Icon(Icons.check_circle, color: colorScheme.primary), + ], + ), + ), + ); + } +} + +class _EulaCheckbox extends StatelessWidget { + final bool accepted; + final ValueChanged onAcceptedChanged; + + const _EulaCheckbox({ + required this.accepted, + required this.onAcceptedChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return CheckboxListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: accepted, + controlAffinity: ListTileControlAffinity.leading, + onChanged: (value) => onAcceptedChanged(value ?? false), + title: Wrap( + children: [ + Text( + 'I have read and accept the ', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + GestureDetector( + onTap: () => _showLegalDocument( + context, + title: 'End User License Agreement', + assetPath: 'assets/legal/EULA.md', + ), + child: Text( + 'EULA', + style: theme.textTheme.bodySmall?.copyWith( + color: BrowserPage.brandYellow, + decoration: TextDecoration.underline, + decorationColor: BrowserPage.brandYellow, + ), + ), + ), + Text( + ' and ', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + GestureDetector( + onTap: () => _showLegalDocument( + context, + title: 'Privacy Policy', + assetPath: 'assets/legal/PRIVACY_POLICY.md', + ), + child: Text( + 'Privacy Policy', + style: theme.textTheme.bodySmall?.copyWith( + color: BrowserPage.brandYellow, + decoration: TextDecoration.underline, + decorationColor: BrowserPage.brandYellow, + ), + ), + ), + Text( + '.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Future _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), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/screens/bang_settings.dart b/app/lib/features/settings/presentation/screens/bang_settings.dart index d9084280..fef4e8d8 100644 --- a/app/lib/features/settings/presentation/screens/bang_settings.dart +++ b/app/lib/features/settings/presentation/screens/bang_settings.dart @@ -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', + ), + ], + ); + }, + ), ), ); } diff --git a/app/lib/features/settings/presentation/screens/doh_settings.dart b/app/lib/features/settings/presentation/screens/doh_settings.dart index 1728be79..09e720d5 100644 --- a/app/lib/features/settings/presentation/screens/doh_settings.dart +++ b/app/lib/features/settings/presentation/screens/doh_settings.dart @@ -18,155 +18,18 @@ * along with this program. If not, see . */ 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()); - - 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 you’re 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()), ), ); } diff --git a/app/lib/features/settings/presentation/screens/general_settings.dart b/app/lib/features/settings/presentation/screens/general_settings.dart index cf6c95ac..dbea9b67 100644 --- a/app/lib/features/settings/presentation/screens/general_settings.dart +++ b/app/lib/features/settings/presentation/screens/general_settings.dart @@ -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(); diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index 50aec927..0427c1e8 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -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(), + ], + ); + }, + ), ), ); } diff --git a/app/lib/features/settings/presentation/screens/toolbar_layout_settings.dart b/app/lib/features/settings/presentation/screens/toolbar_layout_settings.dart index d3e0fd69..429741a4 100644 --- a/app/lib/features/settings/presentation/screens/toolbar_layout_settings.dart +++ b/app/lib/features/settings/presentation/screens/toolbar_layout_settings.dart @@ -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( - 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 = [ - 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), - ); - }, - ); - } -} diff --git a/app/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart b/app/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart index c9a67186..9aec03da 100644 --- a/app/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart +++ b/app/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart @@ -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()), + ), ), ); } diff --git a/app/lib/features/settings/presentation/screens/web_engine_hardening.dart b/app/lib/features/settings/presentation/screens/web_engine_hardening.dart index 03828f5d..5bf2af44 100644 --- a/app/lib/features/settings/presentation/screens/web_engine_hardening.dart +++ b/app/lib/features/settings/presentation/screens/web_engine_hardening.dart @@ -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()), ), ); } diff --git a/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart b/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart index 5304b0d7..e982a088 100644 --- a/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart +++ b/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart @@ -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()), ), ); } diff --git a/app/lib/features/settings/presentation/widgets/doh_settings_content.dart b/app/lib/features/settings/presentation/widgets/doh_settings_content.dart new file mode 100644 index 00000000..14a2a5c5 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/doh_settings_content.dart @@ -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 . + */ +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()); + + 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(); + } + }, + ), + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/toolbar_layout_content.dart b/app/lib/features/settings/presentation/widgets/toolbar_layout_content.dart new file mode 100644 index 00000000..b2a978b3 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/toolbar_layout_content.dart @@ -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 . + */ +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), + ); + }, + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/toolbar_preview.dart b/app/lib/features/settings/presentation/widgets/toolbar_preview.dart new file mode 100644 index 00000000..cb8ab203 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/toolbar_preview.dart @@ -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 . + */ +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( + 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 = [ + 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() {} diff --git a/app/lib/features/tor/presentation/screens/country_picker.dart b/app/lib/features/tor/presentation/screens/country_picker.dart index 0fdfb7d0..f5356171 100644 --- a/app/lib/features/tor/presentation/screens/country_picker.dart +++ b/app/lib/features/tor/presentation/screens/country_picker.dart @@ -99,38 +99,40 @@ class CountryPickerScreen extends HookWidget { ), ), ), - body: ListView.builder( - itemCount: filteredCountries.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = selectedCountryCode == null; + body: SafeArea( + child: ListView.builder( + itemCount: filteredCountries.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = selectedCountryCode == null; + return ListTile( + leading: const SizedBox( + width: 32, + height: 24, + child: Center(child: Icon(Icons.public)), + ), + title: const Text('Automatic'), + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () => context.pop(automaticCountry), + ); + } + + final country = filteredCountries[index - 1]; + final isSelected = country.alpha2Code == selectedCountryCode; + return ListTile( - leading: const SizedBox( - width: 32, - height: 24, - child: Center(child: Icon(Icons.public)), - ), - title: const Text('Automatic'), + leading: country.alpha2Code != null + ? CountryFlag.fromCountryCode( + country.alpha2Code!, + theme: const EmojiTheme(size: 28), + ) + : const SizedBox(width: 32), + title: Text(country.label), trailing: isSelected ? const Icon(Icons.check) : null, - onTap: () => context.pop(automaticCountry), + onTap: () => context.pop(country.alpha2Code), ); - } - - final country = filteredCountries[index - 1]; - final isSelected = country.alpha2Code == selectedCountryCode; - - return ListTile( - leading: country.alpha2Code != null - ? CountryFlag.fromCountryCode( - country.alpha2Code!, - theme: const EmojiTheme(size: 28), - ) - : const SizedBox(width: 32), - title: Text(country.label), - trailing: isSelected ? const Icon(Icons.check) : null, - onTap: () => context.pop(country.alpha2Code), - ); - }, + }, + ), ), ); } diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index b33e89ea..43e028ea 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -239,7 +239,7 @@ class GeneralSettings with FastEquatable { tabBarShowContextualBar = tabBarShowContextualBar ?? true, tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true, tabBarPosition = tabBarPosition ?? TabBarPosition.bottom, - tabBarLayout = tabBarLayout ?? TabBarLayout.withTitle, + tabBarLayout = tabBarLayout ?? TabBarLayout.compact, quickTabSwitcherMode = quickTabSwitcherMode ?? QuickTabSwitcherMode.lastUsedTabs, pullToRefreshEnabled = pullToRefreshEnabled ?? true, diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup.dart b/app/lib/features/user/domain/presentation/screens/profile_backup.dart index 1c1d433a..0b54e652 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_backup.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_backup.dart @@ -66,104 +66,109 @@ class ProfileBackupScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Create Backup')), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: Form( - key: formKey, - child: ListView( - children: [ - FancyPasswordField( - controller: passwordTextController, - enabled: !disableInteraction, - passwordController: passwordController, - enableSuggestions: false, - autocorrect: false, - enableIMEPersonalizedLearning: false, - keyboardType: TextInputType.visiblePassword, - decoration: const InputDecoration( - labelText: 'Password', - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - validationRules: {MinCharactersValidationRule(5)}, - validator: (value) { - //Make sure since onChange is sometimes unreliable - passwordController.onChange(value ?? ''); - - return passwordController.areAllRulesValidated - ? null - : 'Not Validated'; - }, - ), - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: integrityVerification.value, - onChanged: disableInteraction - ? null - : (value) { - integrityVerification.value = value; - }, - title: const Text('Verify Backup Integrity'), - subtitle: const Text( - 'Automatically check that backups are complete and restorable', - ), - ), - ExpansionTile( - enabled: !disableInteraction, - childrenPadding: EdgeInsets.zero, - tilePadding: EdgeInsets.zero, - title: const Text('Advanced'), - children: [ - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: skipPasswordConfirmation.value, - onChanged: disableInteraction - ? null - : (value) { - skipPasswordConfirmation.value = value; - }, - title: const Text('Skip Password Confirmation Prompt'), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Form( + key: formKey, + child: ListView( + children: [ + FancyPasswordField( + controller: passwordTextController, + enabled: !disableInteraction, + passwordController: passwordController, + enableSuggestions: false, + autocorrect: false, + enableIMEPersonalizedLearning: false, + keyboardType: TextInputType.visiblePassword, + decoration: const InputDecoration( + labelText: 'Password', + floatingLabelBehavior: FloatingLabelBehavior.always, ), - ], - ), - const SizedBox(height: 16), - if (disableInteraction) - const Column( - children: [ - LinearProgressIndicator(), - Text('Creating Backup'), - ], - ) - else - FilledButton.icon( - icon: const Icon(MdiIcons.safe), - onPressed: () async { - if (formKey.currentState?.validate() ?? false) { - if (!skipPasswordConfirmation.value) { - final confirmation = - await showPasswordConfirmationDialog(context); + validationRules: {MinCharactersValidationRule(5)}, + validator: (value) { + //Make sure since onChange is sometimes unreliable + passwordController.onChange(value ?? ''); - if (confirmation != passwordTextController.text) { - if (context.mounted) { - showErrorMessage(context, 'Passwords do not match'); - } - - return; - } - } - - backupFuture.value = ref - .read(userBackupServiceProvider.notifier) - .createUserBackup( - profile, - password: passwordTextController.text, - integrityCheck: integrityVerification.value, - ); - } + return passwordController.areAllRulesValidated + ? null + : 'Not Validated'; }, - label: const Text('Backup'), ), - ], + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: integrityVerification.value, + onChanged: disableInteraction + ? null + : (value) { + integrityVerification.value = value; + }, + title: const Text('Verify Backup Integrity'), + subtitle: const Text( + 'Automatically check that backups are complete and restorable', + ), + ), + ExpansionTile( + enabled: !disableInteraction, + childrenPadding: EdgeInsets.zero, + tilePadding: EdgeInsets.zero, + title: const Text('Advanced'), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: skipPasswordConfirmation.value, + onChanged: disableInteraction + ? null + : (value) { + skipPasswordConfirmation.value = value; + }, + title: const Text('Skip Password Confirmation Prompt'), + ), + ], + ), + const SizedBox(height: 16), + if (disableInteraction) + const Column( + children: [ + LinearProgressIndicator(), + Text('Creating Backup'), + ], + ) + else + FilledButton.icon( + icon: const Icon(MdiIcons.safe), + onPressed: () async { + if (formKey.currentState?.validate() ?? false) { + if (!skipPasswordConfirmation.value) { + final confirmation = + await showPasswordConfirmationDialog(context); + + if (confirmation != passwordTextController.text) { + if (context.mounted) { + showErrorMessage( + context, + 'Passwords do not match', + ); + } + + return; + } + } + + backupFuture.value = ref + .read(userBackupServiceProvider.notifier) + .createUserBackup( + profile, + password: passwordTextController.text, + integrityCheck: integrityVerification.value, + ); + } + }, + label: const Text('Backup'), + ), + ], + ), ), ), ), diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart index f03f23be..38dba97d 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart @@ -39,57 +39,61 @@ class ProfileBackupListScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Backups')), - body: backupListAsync.when( - data: (backupList) { - return ListView.builder( - itemCount: backupList.length, - itemBuilder: (context, index) { - final file = backupList[index]; - final match = _filenamePattern.firstMatch(p.basename(file.path)); - - if (match != null) { - final profileName = match.group(1)!; - final datePart = match.group(2)!; - - // Reparse into DateTime - final dateTime = UserBackupService.dateFormatter.decode( - datePart, + body: SafeArea( + child: backupListAsync.when( + data: (backupList) { + return ListView.builder( + itemCount: backupList.length, + itemBuilder: (context, index) { + final file = backupList[index]; + final match = _filenamePattern.firstMatch( + p.basename(file.path), ); - return ListTile( - key: ValueKey(file.path), - title: Text(profileName), - subtitle: Text( - ref.read(formatProvider.notifier).fullDateTime(dateTime), - ), - onTap: () async { - await RestoreProfileRoute( - backupFilePath: file.path, - ).push(context); - }, - ); - } else { - return ListTile( - key: ValueKey(file.path), - title: Text(p.basename(file.path)), - onTap: () async { - await RestoreProfileRoute( - backupFilePath: file.path, - ).push(context); - }, - ); - } - }, - ); - }, - error: (error, stackTrace) => FailureWidget( - title: 'Failed to get backups', - exception: error, - onRetry: () { - ref.invalidate(backupListProvider); + if (match != null) { + final profileName = match.group(1)!; + final datePart = match.group(2)!; + + // Reparse into DateTime + final dateTime = UserBackupService.dateFormatter.decode( + datePart, + ); + + return ListTile( + key: ValueKey(file.path), + title: Text(profileName), + subtitle: Text( + ref.read(formatProvider.notifier).fullDateTime(dateTime), + ), + onTap: () async { + await RestoreProfileRoute( + backupFilePath: file.path, + ).push(context); + }, + ); + } else { + return ListTile( + key: ValueKey(file.path), + title: Text(p.basename(file.path)), + onTap: () async { + await RestoreProfileRoute( + backupFilePath: file.path, + ).push(context); + }, + ); + } + }, + ); }, + error: (error, stackTrace) => FailureWidget( + title: 'Failed to get backups', + exception: error, + onRetry: () { + ref.invalidate(backupListProvider); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), ), - loading: () => const Center(child: CircularProgressIndicator()), ), ); } diff --git a/app/lib/features/user/domain/presentation/screens/profile_edit.dart b/app/lib/features/user/domain/presentation/screens/profile_edit.dart index d4d18711..aec83074 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_edit.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_edit.dart @@ -124,29 +124,33 @@ class ProfileEditScreen extends HookConsumerWidget { ), ], ), - body: Form( - key: formKey, - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: [ - TextFormField( - controller: nameTextController, - decoration: const InputDecoration( - label: Text('Name'), - floatingLabelBehavior: FloatingLabelBehavior.always, + body: SafeArea( + child: Form( + key: formKey, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + children: [ + TextFormField( + controller: nameTextController, + decoration: const InputDecoration( + label: Text('Name'), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + validator: validateProfileName, ), - validator: validateProfileName, - ), - const SizedBox(height: 24), - _AuthSection( - authSettings: authSettings.value, - onAuthSettingsChanged: (newSettings) { - authSettings.value = newSettings; - }, - ), - const SizedBox(height: 24), - if (profile != null) ...[_ProfileActionsSection(profile: profile!)], - ], + const SizedBox(height: 24), + _AuthSection( + authSettings: authSettings.value, + onAuthSettingsChanged: (newSettings) { + authSettings.value = newSettings; + }, + ), + const SizedBox(height: 24), + if (profile != null) ...[ + _ProfileActionsSection(profile: profile!), + ], + ], + ), ), ), ); diff --git a/app/lib/features/user/domain/presentation/screens/profile_list.dart b/app/lib/features/user/domain/presentation/screens/profile_list.dart index 75f97c8d..6c8e3a60 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_list.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_list.dart @@ -46,35 +46,38 @@ class ProfileListScreen extends HookConsumerWidget { ), ], ), - body: usersAsync.when( - skipLoadingOnReload: true, - data: (profiles) => ListView.builder( - itemCount: profiles.length, - itemBuilder: (context, index) { - final profile = profiles[index]; - final isSelected = filesystem.selectedProfile == profile.uuidValue; + body: SafeArea( + child: usersAsync.when( + skipLoadingOnReload: true, + data: (profiles) => ListView.builder( + itemCount: profiles.length, + itemBuilder: (context, index) { + final profile = profiles[index]; + final isSelected = + filesystem.selectedProfile == profile.uuidValue; - return ListTile( - enabled: !isSelected, - leading: const Icon(Icons.person), - title: Text(profile.name), - subtitle: isSelected ? const Text('Active') : null, - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await EditProfileRoute( - profile: jsonEncode(profile.toJson()), - ).push(context); - }, - ); - }, - ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load Profiles', - exception: error, + return ListTile( + enabled: !isSelected, + leading: const Icon(Icons.person), + title: Text(profile.name), + subtitle: isSelected ? const Text('Active') : null, + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await EditProfileRoute( + profile: jsonEncode(profile.toJson()), + ).push(context); + }, + ); + }, ), + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Profiles', + exception: error, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), ), - loading: () => const Center(child: CircularProgressIndicator()), ), floatingActionButton: FloatingActionButton( onPressed: () async { diff --git a/app/lib/features/user/domain/presentation/screens/profile_restore.dart b/app/lib/features/user/domain/presentation/screens/profile_restore.dart index e61cbb54..b79d48f8 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_restore.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_restore.dart @@ -68,107 +68,112 @@ class ProfileRestoreScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Restore Backup')), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: Form( - key: formKey, - child: ListView( - children: [ - TextFormField( - controller: passwordTextController, - enabled: !disableInteraction, - enableSuggestions: false, - autocorrect: false, - enableIMEPersonalizedLearning: false, - keyboardType: TextInputType.visiblePassword, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Password', - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - validator: (value) { - return validateRequired(value, message: 'Password required'); - }, - ), - const SizedBox(height: 16), - RadioGroup( - groupValue: restoreTarget.value, - onChanged: (value) { - if (value != null) { - restoreTarget.value = value; - } - }, - child: Column( - children: [ - RadioListTile( - enabled: !disableInteraction, - value: RestoreTarget.createNew, - title: const Text('Create New User'), - subtitle: const Text('Restore backup as a new user'), - ), - RadioListTile( - enabled: !disableInteraction, - value: RestoreTarget.createOrOverride, - title: const Text('Restore & Replace'), - subtitle: const Text( - 'Restore backup and overwrite existing user if present', - ), - ), - ], - ), - ), - if (restoreTarget.value == RestoreTarget.createNew) + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Form( + key: formKey, + child: ListView( + children: [ TextFormField( - controller: nameTextController, + controller: passwordTextController, enabled: !disableInteraction, + enableSuggestions: false, + autocorrect: false, + enableIMEPersonalizedLearning: false, + keyboardType: TextInputType.visiblePassword, + obscureText: true, decoration: const InputDecoration( - label: Text('Name'), + labelText: 'Password', floatingLabelBehavior: FloatingLabelBehavior.always, ), - validator: validateProfileName, + validator: (value) { + return validateRequired( + value, + message: 'Password required', + ); + }, ), - const SizedBox(height: 16), - if (disableInteraction) - const Column( - children: [ - LinearProgressIndicator(), - Text('Restoring Backup'), - ], - ) - else - FilledButton.icon( - icon: const Icon(MdiIcons.backupRestore), - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - restoreFuture.value = switch (restoreTarget.value) { - RestoreTarget.createOrOverride => - ref - .read(userBackupServiceProvider.notifier) - .restoreAndCreateOrOverride( - backupFile, - password: passwordTextController.text, - confirmOverrideCallback: () { - if (context.mounted) { - return showOverrideProfileDialog(context); - } else { - throw Exception('Override failed'); - } - }, - ), - RestoreTarget.createNew => - ref - .read(userBackupServiceProvider.notifier) - .restoreAndCreateNew( - backupFile, - profileName: nameTextController.text, - password: passwordTextController.text, - ), - }; + const SizedBox(height: 16), + RadioGroup( + groupValue: restoreTarget.value, + onChanged: (value) { + if (value != null) { + restoreTarget.value = value; } }, - label: const Text('Restore'), + child: Column( + children: [ + RadioListTile( + enabled: !disableInteraction, + value: RestoreTarget.createNew, + title: const Text('Create New User'), + subtitle: const Text('Restore backup as a new user'), + ), + RadioListTile( + enabled: !disableInteraction, + value: RestoreTarget.createOrOverride, + title: const Text('Restore & Replace'), + subtitle: const Text( + 'Restore backup and overwrite existing user if present', + ), + ), + ], + ), ), - ], + if (restoreTarget.value == RestoreTarget.createNew) + TextFormField( + controller: nameTextController, + enabled: !disableInteraction, + decoration: const InputDecoration( + label: Text('Name'), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + validator: validateProfileName, + ), + const SizedBox(height: 16), + if (disableInteraction) + const Column( + children: [ + LinearProgressIndicator(), + Text('Restoring Backup'), + ], + ) + else + FilledButton.icon( + icon: const Icon(MdiIcons.backupRestore), + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + restoreFuture.value = switch (restoreTarget.value) { + RestoreTarget.createOrOverride => + ref + .read(userBackupServiceProvider.notifier) + .restoreAndCreateOrOverride( + backupFile, + password: passwordTextController.text, + confirmOverrideCallback: () { + if (context.mounted) { + return showOverrideProfileDialog(context); + } else { + throw Exception('Override failed'); + } + }, + ), + RestoreTarget.createNew => + ref + .read(userBackupServiceProvider.notifier) + .restoreAndCreateNew( + backupFile, + profileName: nameTextController.text, + password: passwordTextController.text, + ), + }; + } + }, + label: const Text('Restore'), + ), + ], + ), ), ), ), diff --git a/app/lib/features/user/domain/presentation/widgets/auth_gate.dart b/app/lib/features/user/domain/presentation/widgets/auth_gate.dart index fe6f13e0..dd9def8d 100644 --- a/app/lib/features/user/domain/presentation/widgets/auth_gate.dart +++ b/app/lib/features/user/domain/presentation/widgets/auth_gate.dart @@ -59,21 +59,23 @@ class LockScreen extends HookConsumerWidget { }); return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(MdiIcons.lock, size: 64), - const SizedBox(height: 16), - const Text('Profile is locked'), - const SizedBox(height: 16), - FilledButton.icon( - style: FilledButton.styleFrom(minimumSize: const Size(160, 40)), - icon: const Icon(MdiIcons.fingerprint), - label: Text(isAuthenticating.value ? 'Unlocking...' : 'Unlock'), - onPressed: isAuthenticating.value ? null : authenticate, - ), - ], + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(MdiIcons.lock, size: 64), + const SizedBox(height: 16), + const Text('Profile is locked'), + const SizedBox(height: 16), + FilledButton.icon( + style: FilledButton.styleFrom(minimumSize: const Size(160, 40)), + icon: const Icon(MdiIcons.fingerprint), + label: Text(isAuthenticating.value ? 'Unlocking...' : 'Unlock'), + onPressed: isAuthenticating.value ? null : authenticate, + ), + ], + ), ), ), ); diff --git a/app/lib/features/user/domain/repositories/onboarding.dart b/app/lib/features/user/domain/repositories/onboarding.dart index 81c8ca76..9fd0d021 100644 --- a/app/lib/features/user/domain/repositories/onboarding.dart +++ b/app/lib/features/user/domain/repositories/onboarding.dart @@ -24,7 +24,7 @@ part 'onboarding.g.dart'; @Riverpod(keepAlive: true) class OnboardingRepository extends _$OnboardingRepository { - static const targetRevision = 2; + static const targetRevision = 3; Future getCurrentRevision() { return ref @@ -43,7 +43,7 @@ class OnboardingRepository extends _$OnboardingRepository { Future isOutdated() async { final current = await getCurrentRevision(); - return current != targetRevision; + return current == null || current < targetRevision; } @override diff --git a/app/lib/features/user/domain/repositories/onboarding.g.dart b/app/lib/features/user/domain/repositories/onboarding.g.dart index 1f858fb6..590917fa 100644 --- a/app/lib/features/user/domain/repositories/onboarding.g.dart +++ b/app/lib/features/user/domain/repositories/onboarding.g.dart @@ -42,7 +42,7 @@ final class OnboardingRepositoryProvider } String _$onboardingRepositoryHash() => - r'937a5fe08c09cc0475270d3707bfb6f087bcf123'; + r'd16657963415392840cdba9f4bcc724963f6da63'; abstract class _$OnboardingRepository extends $Notifier { void build(); diff --git a/app/lib/features/web_feed/presentation/screens/feed_list.dart b/app/lib/features/web_feed/presentation/screens/feed_list.dart index f500f31f..4d0a9c61 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_list.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_list.dart @@ -66,34 +66,36 @@ class FeedListScreen extends HookConsumerWidget { ), ], ), - body: feeds.when( - skipLoadingOnReload: true, - data: (feeds) { - return RefreshIndicator( - onRefresh: () async { - await ref - .read(fetchArticlesControllerProvider.notifier) - .fetchAllArticles(); - }, - child: ListView.builder( - itemCount: feeds.length, - itemBuilder: (context, i) { - return FeedCard(feed: feeds[i]); + body: SafeArea( + child: feeds.when( + skipLoadingOnReload: true, + data: (feeds) { + return RefreshIndicator( + onRefresh: () async { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + }, + child: ListView.builder( + itemCount: feeds.length, + itemBuilder: (context, i) { + return FeedCard(feed: feeds[i]); + }, + ), + ); + }, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Feeds', + exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(feedListProvider); }, ), - ); - }, - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load Feeds', - exception: error, - onRetry: () { - // ignore: unused_result - ref.refresh(feedListProvider); - }, ), + loading: () => const Center(child: CircularProgressIndicator()), ), - loading: () => const Center(child: CircularProgressIndicator()), ), floatingActionButton: FloatingActionButton.extended( label: const Text('Feed'), diff --git a/app/lib/presentation/widgets/browser_page.dart b/app/lib/presentation/widgets/browser_page.dart new file mode 100644 index 00000000..724a2346 --- /dev/null +++ b/app/lib/presentation/widgets/browser_page.dart @@ -0,0 +1,197 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BrowserPage extends ConsumerWidget { + final double bottomViewportInset; + final Widget child; + + const BrowserPage({super.key, this.bottomViewportInset = 0, required this.child}); + + static const brandPurple = Color(0xFF9C83F8); + static const brandYellow = Color(0xFFFBDC6B); + static const brandGrey = Color(0xFFA7A7A7); + + static const auraPurple = Color(0xFF2C2543); + static const auraGold = Color(0xFF3C3827); + static const auraShadow = Color(0xFF222224); + static const auraShadowHighlight = Color(0xFF2D2D31); + static const auraTint = Color(0xFF000000); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.alphaBlend( + auraPurple.withValues(alpha: 0.38), + colorScheme.surfaceContainerLowest, + ), + Color.alphaBlend( + auraShadow.withValues(alpha: 0.72), + colorScheme.surface, + ), + Color.alphaBlend( + auraGold.withValues(alpha: 0.34), + colorScheme.surfaceContainerHigh, + ), + ], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + const Positioned( + top: -70, + left: -120, + child: _BackdropOrb(width: 400, height: 400, color: auraPurple), + ), + const Positioned( + top: 220, + right: -150, + child: _BackdropOrb(width: 340, height: 340, color: auraGold), + ), + const Positioned( + bottom: 18, + left: -8, + child: _BackdropOrb( + width: 320, + height: 320, + color: auraShadowHighlight, + ), + ), + Positioned.fill( + child: IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72), + child: ColoredBox(color: auraTint.withValues(alpha: 0.12)), + ), + ), + ), + ), + Positioned.fill(child: child), + ], + ), + ); + } +} + +class BrowserPageContent extends StatelessWidget { + final double bottomViewportInset; + final Widget child; + + const BrowserPageContent({ + super.key, + this.bottomViewportInset = 0, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB( + 24, + 32, + 24, + 32 + bottomViewportInset, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + constraints.maxHeight - 64 - bottomViewportInset, + ), + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: child, + ), + ), + ), + ); + }, + ); + } +} + +class BrandHeader extends StatelessWidget { + final ColorScheme colorScheme; + + const BrandHeader({super.key, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return Container( + width: 112, + height: 112, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.alphaBlend( + BrowserPage.brandPurple.withValues(alpha: 0.18), + colorScheme.surfaceContainerHighest, + ), + Color.alphaBlend( + BrowserPage.brandYellow.withValues(alpha: 0.12), + colorScheme.surfaceContainer, + ), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.45), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 32, + offset: const Offset(0, 18), + ), + ], + ), + child: Center( + child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72), + ), + ); + } +} + +class _BackdropOrb extends StatelessWidget { + final double width; + final double height; + final Color color; + + const _BackdropOrb({ + required this.width, + required this.height, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + width: width, + height: height, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3018a38f..d0fdd1e6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -115,6 +115,7 @@ flutter: - assets/images/ - assets/landing/ - assets/preferences/ + - assets/legal/ - assets/quotes/ fonts: - family: TorIcons diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 6d324e47..b7d291ea 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -115,6 +115,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { companion object { private const val TAG = "GeckoBrowserApiImpl" private const val FRAGMENT_CONTAINER_ID = 0xBEEF + private const val REQUEST_CODE_BROWSER_ROLE = 1 private var isGeckoInitialized = false } @@ -424,4 +425,33 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { logger.debug("$TAG: Opened custom tab ${tab.id} for $url (private=$`private`)") } + + override fun isDefaultBrowser(): Boolean { + val currentActivity = requireNotNull(activity) { "Activity not attached" } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + val roleManager = currentActivity.getSystemService(android.app.role.RoleManager::class.java) + return roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_BROWSER) + } + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("http://")) + val resolveInfo = currentActivity.packageManager.resolveActivity(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY) + return resolveInfo?.activityInfo?.packageName == currentActivity.packageName + } + + override fun requestDefaultBrowser() { + val currentActivity = requireNotNull(activity) { "Activity not attached" } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + val roleManager = currentActivity.getSystemService(android.app.role.RoleManager::class.java) + if (roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_BROWSER) && + !roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_BROWSER)) { + currentActivity.startActivityForResult( + roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_BROWSER), + REQUEST_CODE_BROWSER_ROLE + ) + return + } + } + val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS) + currentActivity.startActivity(intent) + } + } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 2d416f0d..6c85efc0 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -4845,6 +4845,8 @@ interface GeckoBrowserApi { fun showNativeFragment(): Boolean fun onTrimMemory(level: Long) fun openInCustomTab(url: String, private: Boolean, contextId: String?) + fun isDefaultBrowser(): Boolean + fun requestDefaultBrowser() companion object { /** The codec used by GeckoBrowserApi. */ @@ -4947,6 +4949,37 @@ interface GeckoBrowserApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.isDefaultBrowser$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.isDefaultBrowser()) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.requestDefaultBrowser$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.requestDefaultBrowser() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart index dba77ccd..60fe518d 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart @@ -56,4 +56,12 @@ class GeckoBrowserService { contextId: contextId, ); } + + Future isDefaultBrowser() { + return _api.isDefaultBrowser(); + } + + Future requestDefaultBrowser() { + return _api.requestDefaultBrowser(); + } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 3301d59c..5514fcca 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -5756,6 +5756,55 @@ class GeckoBrowserApi { return; } } + + Future isDefaultBrowser() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.isDefaultBrowser$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future requestDefaultBrowser() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.requestDefaultBrowser$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } class GeckoSyncApi { diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index f02843fb..cbc76601 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1149,6 +1149,8 @@ abstract class GeckoBrowserApi { required bool private, required String? contextId, }); + bool isDefaultBrowser(); + void requestDefaultBrowser(); } enum SyncEngineValue { history, bookmarks, tabs }