diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index 493f53d7..a289dd69 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -291,7 +291,7 @@ class SelectProfileRoute extends GoRouteData with $SelectProfileRoute { @override Page buildPage(BuildContext context, GoRouterState state) { - return DialogPage(builder: (_) => const SelectProfileDialog()); + return BottomSheetPage(builder: (_) => const SelectProfileDialog()); } } diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index f4225b2a..359f9845 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -24,6 +24,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:go_router/go_router.dart'; import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart'; import 'package:weblibre/core/routing/widgets/dialog_page.dart'; import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/about/presentation/screens/about.dart'; diff --git a/app/lib/core/routing/routes.feeds.dart b/app/lib/core/routing/routes.feeds.dart index 28fb2dc2..58d164b4 100644 --- a/app/lib/core/routing/routes.feeds.dart +++ b/app/lib/core/routing/routes.feeds.dart @@ -74,7 +74,7 @@ class SelectFeedDialogRoute extends GoRouteData with $SelectFeedDialogRoute { ), ); - return DialogPage(builder: (_) => SelectFeedDialog(feedUris: feedUris)); + return BottomSheetPage(builder: (_) => SelectFeedDialog(feedUris: feedUris)); } } diff --git a/app/lib/core/routing/widgets/bottom_sheet_page.dart b/app/lib/core/routing/widgets/bottom_sheet_page.dart new file mode 100644 index 00000000..6d67fbb2 --- /dev/null +++ b/app/lib/core/routing/widgets/bottom_sheet_page.dart @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024-2025 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'; + +/// A bottom sheet page with Material entrance and exit animations. +/// Similar to DialogPage but displays content as a modal bottom sheet. +class BottomSheetPage extends Page { + final WidgetBuilder builder; + final Color? barrierColor; + final bool barrierDismissible; + final String? barrierLabel; + final bool isScrollControlled; + final bool useSafeArea; + + const BottomSheetPage({ + required this.builder, + this.barrierColor, + this.barrierDismissible = true, + this.barrierLabel, + this.isScrollControlled = true, + this.useSafeArea = true, + super.key, + super.name, + super.arguments, + super.restorationId, + }); + + @override + Route createRoute(BuildContext context) => ModalBottomSheetRoute( + settings: this, + builder: builder, + barrierLabel: barrierLabel ?? + MaterialLocalizations.of(context).modalBarrierDismissLabel, + backgroundColor: + Theme.of(context).bottomSheetTheme.modalBackgroundColor, + elevation: Theme.of(context).bottomSheetTheme.modalElevation, + shape: Theme.of(context).bottomSheetTheme.shape, + clipBehavior: Clip.antiAlias, + constraints: Theme.of(context).bottomSheetTheme.constraints, + isScrollControlled: isScrollControlled, + isDismissible: barrierDismissible, + useSafeArea: useSafeArea, + ); +} diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/delete_data.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/delete_data.dart index d4116914..0f8eb6a8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/delete_data.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/delete_data.dart @@ -25,55 +25,78 @@ import 'package:nullability/nullability.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; -class DeleteDataDialog extends HookConsumerWidget { +/// Shows a bottom sheet to select and delete browsing data. +Future showDeleteDataDialog( + BuildContext context, { + Set initialSettings = const {}, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _DeleteDataSheet(initialSettings: initialSettings), + ); +} + +class _DeleteDataSheet extends HookConsumerWidget { final Set initialSettings; - const DeleteDataDialog({required this.initialSettings}); + const _DeleteDataSheet({required this.initialSettings}); @override Widget build(BuildContext context, WidgetRef ref) { final selections = useState(initialSettings); - return SimpleDialog( - title: const Text('Delete Browsing Data'), - children: [ - for (final type in DeleteBrowsingDataType.values) - CheckboxListTile.adaptive( - value: selections.value.contains(type), - controlAffinity: ListTileControlAffinity.leading, - title: Text(type.title), - subtitle: type.description.mapNotNull( - (description) => Text(description), + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Delete Browsing Data', + style: Theme.of(context).textTheme.titleLarge, ), - onChanged: (value) { - if (value == true) { - selections.value = {...selections.value, type}; - } else { - selections.value = {...selections.value}..remove(type); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: FilledButton.icon( - onPressed: () async { - await ref - .read(browserDataServiceProvider.notifier) - .deleteData(selections.value); + const SizedBox(height: 16), + for (final type in DeleteBrowsingDataType.values) + CheckboxListTile.adaptive( + value: selections.value.contains(type), + controlAffinity: ListTileControlAffinity.leading, + title: Text(type.title), + subtitle: type.description.mapNotNull( + (description) => Text(description), + ), + onChanged: (value) { + if (value == true) { + selections.value = {...selections.value, type}; + } else { + selections.value = {...selections.value}..remove(type); + } + }, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: selections.value.isEmpty + ? null + : () async { + await ref + .read(browserDataServiceProvider.notifier) + .deleteData(selections.value); - if (context.mounted) { - context.pop(); - } - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, + if (context.mounted) { + context.pop(); + } + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete_forever), ), - label: const Text('Delete'), - icon: const Icon(Icons.delete_forever), - ), + ], ), - ], + ), ); } } diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart index a92bf8b5..0b043514 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart @@ -21,46 +21,68 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart'; -/// Dialog to select a bookmark folder. +/// Bottom sheet to select a bookmark folder. /// Returns the selected folder GUID or null if cancelled. Future showSelectFolderDialog(BuildContext context) { - return showDialog( + return showModalBottomSheet( context: context, - builder: (context) => const _SelectFolderDialog(), + isScrollControlled: true, + builder: (context) => const _SelectFolderSheet(), ); } -class _SelectFolderDialog extends HookConsumerWidget { - const _SelectFolderDialog(); +class _SelectFolderSheet extends HookConsumerWidget { + const _SelectFolderSheet(); @override Widget build(BuildContext context, WidgetRef ref) { final selectedFolderGuid = useState(BookmarkRoot.mobile.id); - return AlertDialog( - title: const Text('Select folder'), - content: SizedBox( - width: double.maxFinite, - child: SingleChildScrollView( - child: FolderTreePicker( - selectedFolderGuid: selectedFolderGuid, - entryGuid: BookmarkRoot.root.id, - ), + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Select folder', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + child: SingleChildScrollView( + child: FolderTreePicker( + selectedFolderGuid: selectedFolderGuid, + entryGuid: BookmarkRoot.root.id, + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => context.pop(selectedFolderGuid.value), + child: const Text('Select'), + ), + ], + ), + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(selectedFolderGuid.value), - child: const Text('Select'), - ), - ], ); } } diff --git a/app/lib/features/geckoview/features/history/presentation/screens/history.dart b/app/lib/features/geckoview/features/history/presentation/screens/history.dart index e30a3cca..ae6bc49d 100644 --- a/app/lib/features/geckoview/features/history/presentation/screens/history.dart +++ b/app/lib/features/geckoview/features/history/presentation/screens/history.dart @@ -280,13 +280,9 @@ class HistoryScreen extends HookConsumerWidget { else IconButton( onPressed: () async { - await showDialog( - context: context, - builder: (context) { - return const DeleteDataDialog( - initialSettings: {DeleteBrowsingDataType.history}, - ); - }, + await showDeleteDataDialog( + context, + initialSettings: {DeleteBrowsingDataType.history}, ); // ignore: unused_result diff --git a/app/lib/features/settings/presentation/screens/privacy_security_settings.dart b/app/lib/features/settings/presentation/screens/privacy_security_settings.dart index ca8762fb..12a20f5a 100644 --- a/app/lib/features/settings/presentation/screens/privacy_security_settings.dart +++ b/app/lib/features/settings/presentation/screens/privacy_security_settings.dart @@ -254,12 +254,7 @@ class _DeleteBrowsingDataTile extends StatelessWidget { leading: const Icon(MdiIcons.databaseRemove), trailing: const Icon(Icons.chevron_right), onTap: () async { - await showDialog( - context: context, - builder: (context) { - return const DeleteDataDialog(initialSettings: {}); - }, - ); + await showDeleteDataDialog(context); }, ); } diff --git a/app/lib/features/user/domain/presentation/dialogs/select_profile.dart b/app/lib/features/user/domain/presentation/dialogs/select_profile.dart index 529fe3d1..9e589410 100644 --- a/app/lib/features/user/domain/presentation/dialogs/select_profile.dart +++ b/app/lib/features/user/domain/presentation/dialogs/select_profile.dart @@ -28,6 +28,7 @@ import 'package:weblibre/features/user/domain/repositories/profile.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/utils/exit_app.dart'; +/// Bottom sheet widget to select a user profile. class SelectProfileDialog extends HookConsumerWidget { const SelectProfileDialog(); @@ -35,58 +36,76 @@ class SelectProfileDialog extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final usersAsync = ref.watch(profileRepositoryProvider); - return AlertDialog( - title: const Text('Users'), - scrollable: true, - content: usersAsync.when( - skipLoadingOnReload: true, - data: (profiles) => Column( - children: profiles.map((profile) { - final isSelected = filesystem.selectedProfile == profile.uuidValue; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Users', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + usersAsync.when( + skipLoadingOnReload: true, + data: (profiles) => Column( + mainAxisSize: MainAxisSize.min, + children: profiles.map((profile) { + final isSelected = + filesystem.selectedProfile == profile.uuidValue; - return ListTile( - key: ValueKey(profile.id), - enabled: !isSelected, - trailing: !isSelected ? const Icon(MdiIcons.accountSwitch) : null, - title: Text(profile.name), - subtitle: isSelected ? const Text('Active') : null, - onTap: () async { - await handleSwitchProfile(context, ref, profile); - }, - ); - }).toList(), + return ListTile( + key: ValueKey(profile.id), + enabled: !isSelected, + trailing: + !isSelected ? const Icon(MdiIcons.accountSwitch) : null, + title: Text(profile.name), + subtitle: isSelected ? const Text('Active') : null, + onTap: () async { + await handleSwitchProfile(context, ref, profile); + }, + ); + }).toList(), + ), + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Profiles', + exception: error, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + icon: const Icon(MdiIcons.power), + iconAlignment: IconAlignment.start, + label: const Text('Quit Browser'), + onPressed: () async { + final result = await showQuitBrowserDialog(context); + + if (result == true) { + await exitApp(ref.container); + } + }, + ), + TextButton.icon( + icon: const Icon(MdiIcons.accountGroup), + iconAlignment: IconAlignment.end, + label: const Text('Manage'), + onPressed: () async { + await ProfileListRoute().push(context); + }, + ), + ], + ), + ], ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load Profiles', - exception: error, - ), - ), - loading: () => const Center(child: CircularProgressIndicator()), ), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - TextButton.icon( - icon: const Icon(MdiIcons.power), - iconAlignment: IconAlignment.start, - label: const Text('Quit Browser'), - onPressed: () async { - final result = await showQuitBrowserDialog(context); - - if (result == true) { - await exitApp(ref.container); - } - }, - ), - TextButton.icon( - icon: const Icon(MdiIcons.accountGroup), - iconAlignment: IconAlignment.end, - label: const Text('Manage'), - onPressed: () async { - await ProfileListRoute().push(context); - }, - ), - ], ); } } diff --git a/app/lib/features/web_feed/presentation/select_feed_dialog.dart b/app/lib/features/web_feed/presentation/select_feed_dialog.dart index 95aef1f9..b46c0c53 100644 --- a/app/lib/features/web_feed/presentation/select_feed_dialog.dart +++ b/app/lib/features/web_feed/presentation/select_feed_dialog.dart @@ -25,6 +25,7 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/web_feed/domain/providers.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; +/// Bottom sheet widget to select a feed from discovered feeds. class SelectFeedDialog extends HookConsumerWidget { final Set feedUris; @@ -32,47 +33,58 @@ class SelectFeedDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return SimpleDialog( - title: const Text('Add Feed'), - children: feedUris - .map( - (uri) => HookConsumer( - builder: (context, ref, child) { - final feedAsync = ref.watch(fetchWebFeedProvider(uri)); - - return feedAsync.when( - skipLoadingOnReload: true, - data: (data) { - return ListTile( - title: Text( - data.feedData.title.whenNotEmpty ?? 'Unnamed Feed', - ), - subtitle: Text(uri.toString()), - trailing: const Icon(Icons.add), - onTap: () { - FeedCreateRoute(feedId: uri).pushReplacement(context); - }, - ); - }, - error: (error, stackTrace) => FailureWidget( - title: 'Failed to fetch Feed', - exception: error, - onRetry: () { - // ignore: unused_result - ref.refresh(fetchWebFeedProvider(uri)); - }, - ), - loading: () => Skeletonizer( - child: ListTile( - title: Text(BoneMock.title), - subtitle: Skeleton.keep(child: Text(uri.toString())), - ), - ), - ); - }, + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Add Feed', + style: Theme.of(context).textTheme.titleLarge, ), - ) - .toList(), + const SizedBox(height: 16), + ...feedUris.map( + (uri) => HookConsumer( + builder: (context, ref, child) { + final feedAsync = ref.watch(fetchWebFeedProvider(uri)); + + return feedAsync.when( + skipLoadingOnReload: true, + data: (data) { + return ListTile( + title: Text( + data.feedData.title.whenNotEmpty ?? 'Unnamed Feed', + ), + subtitle: Text(uri.toString()), + trailing: const Icon(Icons.add), + onTap: () { + FeedCreateRoute(feedId: uri).pushReplacement(context); + }, + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Failed to fetch Feed', + exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(fetchWebFeedProvider(uri)); + }, + ), + loading: () => Skeletonizer( + child: ListTile( + title: Text(BoneMock.title), + subtitle: Skeleton.keep(child: Text(uri.toString())), + ), + ), + ); + }, + ), + ), + ], + ), + ), ); } }