refactor tab menu

This commit is contained in:
Fabian Freund
2025-08-01 20:51:53 +02:00
parent f6c68b8431
commit 37f11f1bd5
15 changed files with 660 additions and 700 deletions
-15
View File
@@ -23,7 +23,6 @@ part of 'routes.dart';
name: BrowserRoute.name,
path: '/',
routes: [
TypedGoRoute<WebPageRoute>(name: 'WebPageRoute', path: 'page/:url'),
TypedGoRoute<SearchRoute>(
name: 'SearchRoute',
path: 'search/:tabType/:searchText',
@@ -66,20 +65,6 @@ class BrowserRoute extends GoRouteData with _$BrowserRoute {
}
}
class WebPageRoute extends GoRouteData with _$WebPageRoute {
final String url;
final WebPageInfo? $extra;
const WebPageRoute({required this.url, required this.$extra});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(
builder: (_) => WebPageDialog(url: Uri.parse(url), precachedInfo: $extra),
);
}
}
enum TabType { regular, private, child }
class SearchRoute extends GoRouteData with _$SearchRoute {
-2
View File
@@ -22,13 +22,11 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/data/models/web_page_info.dart';
import 'package:weblibre/features/about/presentation/screens/about.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
import 'package:weblibre/features/bangs/presentation/screens/list.dart';
import 'package:weblibre/features/bangs/presentation/screens/search.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
-34
View File
@@ -283,12 +283,6 @@ RouteBase get $browserRoute => GoRouteData.$route(
factory: _$BrowserRoute._fromState,
routes: [
GoRouteData.$route(
path: 'page/:url',
name: 'WebPageRoute',
factory: _$WebPageRoute._fromState,
),
GoRouteData.$route(
path: 'search/:tabType/:searchText',
name: 'SearchRoute',
@@ -362,34 +356,6 @@ mixin _$BrowserRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location);
}
mixin _$WebPageRoute on GoRouteData {
static WebPageRoute _fromState(GoRouterState state) => WebPageRoute(
url: state.pathParameters['url']!,
$extra: state.extra as WebPageInfo?,
);
WebPageRoute get _self => this as WebPageRoute;
@override
String get location =>
GoRouteData.$location('/page/${Uri.encodeComponent(_self.url)}');
@override
void go(BuildContext context) => context.go(location, extra: _self.$extra);
@override
Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: _self.$extra);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location, extra: _self.$extra);
@override
void replace(BuildContext context) =>
context.replace(location, extra: _self.$extra);
}
mixin _$SearchRoute on GoRouteData {
static SearchRoute _fromState(GoRouterState state) => SearchRoute(
tabType: _$TabTypeEnumMap._$fromName(state.pathParameters['tabType']!)!,
@@ -17,41 +17,45 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/providers/search.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class SiteSearch extends HookConsumerWidget {
final String domain;
final List<BangData> availableBangs;
final String? initialText;
final bool searchInNewTab;
const SiteSearch({
required this.domain,
required this.availableBangs,
super.key,
this.searchInNewTab = true,
this.initialText,
this.searchInNewTab = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final searchTextController = useTextEditingController();
final searchTextController = useTextEditingController(text: initialText);
final searchFocusNode = useFocusNode();
useListenableCallback(searchTextController, () {
ref
@@ -60,44 +64,55 @@ class SiteSearch extends HookConsumerWidget {
});
final selectedBang = ref.watch(selectedBangDataProvider(domain: domain));
final defaultSearchBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.valueOrNull),
);
final activeBang = selectedBang ?? availableBangs.firstOrNull;
Future<void> submitSearch(String value) async {
if (value.isNotEmpty) {
final bang = selectedBang ?? defaultSearchBang;
Future<void> submitSearch(String query) async {
if (activeBang != null && (formKey.currentState?.validate() == true)) {
final isPrivate =
ref.read(selectedTabStateProvider)?.isPrivate ?? false;
final newUrl = uri_parser.tryParseUrl(value, eagerParsing: true);
final searchUri = activeBang.getTemplateUrl(query);
if (!isPrivate) {
await ref
.read(bangSearchProvider.notifier)
.triggerBangSearch(activeBang, query);
}
if (searchInNewTab) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: searchUri, private: isPrivate);
} else {
if (newUrl != null) {
await ref
.read(tabSessionProvider(tabId: null).notifier)
.loadUrl(url: searchUri);
}
.loadUrl(url: newUrl);
if (context.mounted) {
context.pop();
ref.read(bottomSheetControllerProvider.notifier).dismiss();
} else if (bang != null) {
final isPrivate =
ref.read(selectedTabStateProvider)?.isPrivate ?? false;
final searchUri = bang.getTemplateUrl(value);
if (!isPrivate) {
await ref
.read(bangSearchProvider.notifier)
.triggerBangSearch(bang, value);
}
if (searchInNewTab) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: searchUri, private: isPrivate);
} else {
await ref
.read(tabSessionProvider(tabId: null).notifier)
.loadUrl(url: searchUri);
}
ref.read(bottomSheetControllerProvider.notifier).dismiss();
}
}
}
return Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Visibility(
visible: availableBangs.isNotEmpty,
child: SizedBox(
height: 48,
width: double.maxFinite,
child: SelectableChips(
@@ -109,6 +124,18 @@ class SiteSearch extends HookConsumerWidget {
availableItems: availableBangs,
selectedItem: selectedBang,
onSelected: (bang) {
if (selectedBang == null) {
final hasSupportedScheme =
uri_parser
.tryParseUrl(searchTextController.text)
.mapNotNull((uri) => uri.hasSupportedScheme) ??
false;
if (hasSupportedScheme) {
searchTextController.clear();
}
}
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.setTrigger(bang.trigger);
@@ -125,37 +152,33 @@ class SiteSearch extends HookConsumerWidget {
},
),
),
SearchField(
textEditingController: searchTextController,
activeBang: activeBang,
onSubmitted: (_) async {
await submitSearch(searchTextController.text);
},
showSuggestions: false,
),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
shrinkWrap: true,
controller: controller,
slivers: [
SearchTermSuggestions(
searchTextController: searchTextController,
activeBang: activeBang,
submitSearch: submitSearch,
showHistory: false,
showChips: false,
),
],
);
},
),
),
],
),
),
SearchField(
textEditingController: searchTextController,
focusNode: searchFocusNode,
maxLines: null,
activeBang: selectedBang,
showSuggestions: true,
onTap: () {
if (!searchFocusNode.hasFocus) {
// Select all text when the field is tapped
searchTextController.selection = TextSelection(
baseOffset: 0,
extentOffset: searchTextController.text.length,
);
}
},
unfocusOnTapOutside: false,
onSubmitted: (value) async {
await submitSearch(value);
},
),
FixedSearchTermSuggestions(
searchTextController: searchTextController,
activeBang: selectedBang,
submitSearch: submitSearch,
),
],
);
}
}
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
sealed class Sheet with FastEquatable {}
@@ -26,11 +27,11 @@ final class ViewTabsSheet extends Sheet {
List<Object?> get hashParameters => [null];
}
final class TabQaChatSheet extends Sheet {
final String chatId;
final class EditUrlSheet extends Sheet {
final TabState tabState;
TabQaChatSheet({required this.chatId});
EditUrlSheet({required this.tabState});
@override
List<Object?> get hashParameters => [chatId];
List<Object?> get hashParameters => [tabState];
}
@@ -1,350 +0,0 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:ui' as ui;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/data/models/web_page_info.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/presentation/widgets/site_search.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/share_tile.dart';
import 'package:weblibre/presentation/widgets/website_feed_tile.dart';
import 'package:weblibre/presentation/widgets/website_title_tile.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class WebPageDialog extends HookConsumerWidget {
final Uri url;
final WebPageInfo? precachedInfo;
const WebPageDialog({required this.url, this.precachedInfo, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final incognitoEnabled = ref.watch(incognitoModeEnabledProvider);
final availableBangsAsync = ref.watch(
bangListProvider(domain: url.host, orderMostFrequentFirst: true),
);
final availableBangCount = availableBangsAsync.valueOrNull?.length;
return MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: Dialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 64.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
child: WebsiteTitleTile(url, precachedInfo: precachedInfo),
),
CertificateTile(),
const Divider(),
SizedBox(
//We need this to stretch the dialog, then padding from dialog is applied
width: double.maxFinite,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: AddressWithSuggestionsField(
url: url,
incognitoEnabled: incognitoEnabled,
),
),
),
const SizedBox(height: 8),
Flexible(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: availableBangsAsync.when(
skipLoadingOnReload: true,
data: (availableBangs) {
if (availableBangs.isEmpty) {
return const SizedBox.shrink();
}
return SiteSearch(
domain: url.host,
availableBangs: availableBangs,
searchInNewTab: false,
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load bangs',
exception: error,
),
loading: () => SiteSearch(
domain: url.host,
availableBangs: const [
// BangData(
// websiteName: 'websiteName',
// domain: 'domain',
// trigger: 'trigger',
// urlTemplate: 'urlTemplate',
// ),
],
),
),
),
if (availableBangsAsync.isLoading ||
availableBangCount == null ||
availableBangCount > 0)
const Divider(),
ListTile(
leading: const Icon(MdiIcons.contentCopy),
title: const Text('Copy address'),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: url.toString()),
);
if (context.mounted) {
context.pop();
}
},
),
ListTile(
onTap: () async {
await ui_helper.launchUrlFeedback(context, url);
},
leading: const Icon(Icons.open_in_browser),
title: const Text('Launch External'),
),
ListTile(
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Clone tab'),
onTap: () async {
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: url);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(
tabRepositoryProvider.notifier,
);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
context.pop();
}
},
),
ListTile(
leading: const Icon(MdiIcons.tabUnselected),
title: const Text('Clone as private tab'),
onTap: () async {
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: url, private: true);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(
tabRepositoryProvider.notifier,
);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
context.pop();
}
},
),
ListTile(
leading: const Icon(
MdiIcons.folderArrowUpDownOutline,
),
title: const Text('Assign container'),
onTap: () async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId != null) {
final targetContainerId =
await ContainerSelectionRoute().push<String?>(
context,
);
if (targetContainerId != null) {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(targetContainerId);
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(
selectedTabId,
containerData,
);
}
}
if (context.mounted) {
context.pop();
}
}
},
),
Consumer(
child: ListTile(
leading: const Icon(MdiIcons.folderCancelOutline),
title: const Text('Unassign container'),
onTap: () async {
final selectedTabId = ref.read(
selectedTabProvider,
);
if (selectedTabId != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.unassignContainer(selectedTabId);
}
if (context.mounted) {
context.pop();
}
},
),
builder: (context, ref, child) {
final containerId = ref.watch(
selectedTabContainerIdProvider.select(
(value) => value.valueOrNull,
),
);
return Visibility(
visible: containerId != null,
child: child!,
);
},
),
ShareTile(
onTap: () async {
await SharePlus.instance.share(
ShareParams(uri: url),
);
if (context.mounted) {
context.pop();
}
},
onTapQr: () async {
await showQrCode(context, url.toString());
},
),
ListTile(
leading: const Icon(Icons.mobile_screen_share),
title: const Text('Share screenshot'),
onTap: () async {
final screenshot = await ref
.read(selectedTabSessionNotifierProvider)
.requestScreenshot();
if (screenshot != null) {
ui.decodeImageFromList(screenshot, (
result,
) async {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) {
final file = XFile.fromData(
png.buffer.asUint8List(),
mimeType: 'image/png',
);
await SharePlus.instance.share(
ShareParams(
files: [file],
subject: precachedInfo?.title,
),
);
}
});
}
if (context.mounted) {
context.pop();
}
},
),
WebsiteFeedTile(url, precachedInfo: precachedInfo),
],
),
);
},
),
),
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 16.0),
child: TextButton(
onPressed: () {
context.pop();
},
child: const Text('Close'),
),
),
),
],
),
),
);
}
}
@@ -30,6 +30,7 @@ import 'package:weblibre/data/models/drag_data.dart';
import 'package:weblibre/extensions/media_query.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
@@ -40,7 +41,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/entities/she
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.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/browser_modules/browser_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
@@ -262,10 +263,10 @@ class BrowserScreen extends HookConsumerWidget {
key: ValueKey(displayedSheet),
maxChildSize: MediaQuery.of(context).relativeSafeArea(),
),
final TabQaChatSheet parameter => _QaSheet(
final EditUrlSheet parameter => _ViewUrlSheet(
key: ValueKey(displayedSheet),
initialTabState: parameter.tabState,
maxChildSize: MediaQuery.of(context).relativeSafeArea(),
chatId: parameter.chatId,
),
},
)
@@ -353,11 +354,15 @@ class _BrowserView extends StatelessWidget {
}
}
class _QaSheet extends HookConsumerWidget {
final String chatId;
class _ViewUrlSheet extends HookConsumerWidget {
final double maxChildSize;
final TabState initialTabState;
const _QaSheet({super.key, required this.chatId, this.maxChildSize = 1.0});
const _ViewUrlSheet({
super.key,
required this.initialTabState,
this.maxChildSize = 1.0,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -366,6 +371,7 @@ class _QaSheet extends HookConsumerWidget {
return DraggableScrollableSheet(
controller: draggableScrollableController,
expand: false,
initialChildSize: 172.0 / MediaQuery.of(context).size.height,
minChildSize: 0.1,
maxChildSize: maxChildSize,
builder: (context, scrollController) {
@@ -374,28 +380,13 @@ class _QaSheet extends HookConsumerWidget {
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
child: Column(
children: [
DraggableScrollableHeader(
controller: draggableScrollableController,
child: Material(
child: SizedBox(
width: double.infinity,
child: Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 16.0),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
),
],
child: ViewTabSheetWidget(
initialTabState: initialTabState,
sheetScrollController: scrollController,
draggableScrollableController: draggableScrollableController,
onClose: () {
ref.read(bottomSheetControllerProvider.notifier).dismiss();
},
),
);
},
@@ -35,7 +35,6 @@ import 'package:weblibre/features/geckoview/domain/providers/web_extensions_stat
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.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/edit_url_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart';
@@ -77,30 +76,13 @@ class BrowserBottomAppBar extends HookConsumerWidget {
height: AppBar().preferredSize.height,
padding: EdgeInsets.zero,
child: GestureDetector(
onTap: () async {
onTap: () {
final tabState = ref.read(selectedTabStateProvider);
if (tabState != null) {
await WebPageRoute(
url: tabState.url.toString(),
$extra: tabState,
).push(context);
}
},
onLongPress: () async {
final tabState = ref.read(selectedTabStateProvider);
if (tabState != null) {
final newUrl = await showDialog<Uri?>(
context: context,
builder: (context) => EditUrlDialog(initialUrl: tabState.url),
);
if (newUrl != null) {
await ref
.read(tabSessionProvider(tabId: null).notifier)
.loadUrl(url: newUrl);
}
ref
.read(bottomSheetControllerProvider.notifier)
.show(EditUrlSheet(tabState: tabState));
}
},
onHorizontalDragStart: (details) {
@@ -1,125 +0,0 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class EditUrlDialog extends HookConsumerWidget {
final Uri initialUrl;
const EditUrlDialog({required this.initialUrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final addressTextController = useTextEditingController(
text: initialUrl.toString(),
);
return AlertDialog(
title: const Text('Edit Address'),
content: HookBuilder(
builder: (context) {
final addressTextFocusNode = useFocusNode();
final suggestion = useState<String?>(null);
final lastText = useRef<String>(addressTextController.text);
useListenableCallback(addressTextController, () async {
if (addressTextController.text.isNotEmpty) {
if (suggestion.value.isNotEmpty &&
lastText.value.length > 1 &&
addressTextController.text ==
lastText.value.substring(0, lastText.value.length - 1)) {
suggestion.value = null;
addressTextController.text = lastText.value;
} else if (addressTextController.text != lastText.value) {
final result = await ref
.read(engineSuggestionsProvider.notifier)
.getAutocompleteSuggestion(addressTextController.text);
suggestion.value = result;
}
}
lastText.value = addressTextController.text;
});
return Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AutoSuggestTextField(
controller: addressTextController,
focusNode: addressTextFocusNode,
suggestion: suggestion.value,
// enableIMEPersonalizedLearning: !incognitoEnabled,
maxLines: null,
keyboardType: TextInputType.url,
decoration: const InputDecoration(hintText: 'Enter URL'),
onTap: () {
if (!addressTextFocusNode.hasFocus) {
// Select all text when the field is tapped
addressTextController.selection = TextSelection(
baseOffset: 0,
extentOffset: addressTextController.text.length,
);
}
},
validator: (value) {
return validateUrl(value, requireAuthority: false);
},
),
ClipboardFillLink(controller: addressTextController),
],
),
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(context).pop(
uri_parser.tryParseUrl(
addressTextController.text,
eagerParsing: true,
),
);
}
},
child: const Text('Edit'),
),
],
);
}
}
@@ -0,0 +1,378 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:ui' as ui;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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:share_plus/share_plus.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/presentation/widgets/site_search.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/presentation/widgets/share_tile.dart';
import 'package:weblibre/presentation/widgets/website_feed_tile.dart';
import 'package:weblibre/presentation/widgets/website_title_tile.dart';
import 'package:weblibre/utils/debouncer.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class ClampingScrollPhysicsWithoutImplicit extends ClampingScrollPhysics {
const ClampingScrollPhysicsWithoutImplicit({super.parent});
@override
ClampingScrollPhysicsWithoutImplicit applyTo(ScrollPhysics? ancestor) {
return ClampingScrollPhysicsWithoutImplicit(parent: buildParent(ancestor));
}
@override
bool get allowImplicitScrolling => false;
}
class ViewTabSheetWidget extends HookConsumerWidget {
final TabState initialTabState;
final ScrollController sheetScrollController;
final DraggableScrollableController draggableScrollableController;
final VoidCallback onClose;
const ViewTabSheetWidget({
required this.initialTabState,
required this.sheetScrollController,
required this.draggableScrollableController,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final headerKey = useMemoized(() => GlobalKey());
final textFieldKey = useMemoized(() => GlobalKey());
final availableBangs = ref.watch(
bangListProvider(
domain: initialTabState.url.host,
orderMostFrequentFirst: true,
).select((value) => value.valueOrNull ?? const []),
);
final initialScrollDebouncer = useMemoized(
() => Debouncer(const Duration(milliseconds: 150)),
);
useEffect(() {
if (!initialScrollDebouncer.hasRan) {
initialScrollDebouncer.eventOccured(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final header = headerKey.currentContext?.findRenderObject();
final text = textFieldKey.currentContext?.findRenderObject();
if (header case final RenderBox headerBox) {
if (text case final RenderBox textBox) {
final totalHeight =
headerBox.size.height +
textBox.size.height +
kToolbarHeight;
final relative =
totalHeight / MediaQuery.of(context).size.height;
draggableScrollableController.jumpTo(relative);
}
}
});
});
}
return null;
});
// final changes = useRef(0.0);
// useEffect(() {
// final emitter = KeyboardHeightEmitter();
// emitter.onKeyboardHeightChanged((height) {
// final diff =
// height / MediaQuery.of(context).size.height - changes.value;
// draggableScrollableController.jumpTo(
// draggableScrollableController.size + diff,
// );
// changes.value += diff;
// });
// return () => emitter.dispose();
// }, []);
final changes = useRef(0.0);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final diff =
((MediaQuery.of(context).viewInsets.bottom / 2) /
MediaQuery.of(context).size.height) -
changes.value;
draggableScrollableController.jumpTo(
draggableScrollableController.size + diff,
);
changes.value += diff;
});
return null;
}, [MediaQuery.of(context).viewInsets.bottom]);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DraggableScrollableHeader(
key: headerKey,
controller: draggableScrollableController,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
child: GestureDetector(
onTap: () {
draggableScrollableController.jumpTo(1.0);
},
child: WebsiteTitleTile(initialTabState),
),
),
CertificateTile(),
const Divider(),
],
),
),
Flexible(
child: FadingScroll(
fadingSize: 25,
controller: sheetScrollController,
builder: (context, controller) {
return ListView(
controller: controller,
physics: const ClampingScrollPhysicsWithoutImplicit(),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SiteSearch(
key: textFieldKey,
domain: initialTabState.url.host,
availableBangs: availableBangs,
initialText: initialTabState.url.toString(),
),
),
const Divider(),
ListTile(
leading: const Icon(MdiIcons.contentCopy),
title: const Text('Copy address'),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: initialTabState.url.toString()),
);
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
),
ListTile(
onTap: () async {
await ui_helper.launchUrlFeedback(
context,
initialTabState.url,
);
},
leading: const Icon(Icons.open_in_browser),
title: const Text('Launch External'),
),
ListTile(
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Clone tab'),
onTap: () async {
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: initialTabState.url);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
}
},
),
ListTile(
leading: const Icon(MdiIcons.tabUnselected),
title: const Text('Clone as private tab'),
onTap: () async {
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: initialTabState.url, private: true);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
}
},
),
ListTile(
leading: const Icon(MdiIcons.folderArrowUpDownOutline),
title: const Text('Assign container'),
onTap: () async {
final targetContainerId = await ContainerSelectionRoute()
.push<String?>(context);
if (targetContainerId != null) {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(targetContainerId);
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(
initialTabState.id,
containerData,
);
}
}
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
),
Consumer(
child: ListTile(
leading: const Icon(MdiIcons.folderCancelOutline),
title: const Text('Unassign container'),
onTap: () async {
await ref
.read(tabDataRepositoryProvider.notifier)
.unassignContainer(initialTabState.id);
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
),
builder: (context, ref, child) {
final containerId = ref.watch(
watchContainerTabIdProvider(
initialTabState.id,
).select((value) => value.valueOrNull),
);
return Visibility(
visible: containerId != null,
child: child!,
);
},
),
ShareTile(
onTap: () async {
await SharePlus.instance.share(
ShareParams(uri: initialTabState.url),
);
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
onTapQr: () async {
await showQrCode(context, initialTabState.url.toString());
},
),
ListTile(
leading: const Icon(Icons.mobile_screen_share),
title: const Text('Share screenshot'),
onTap: () async {
final screenshot = await ref
.read(selectedTabSessionNotifierProvider)
.requestScreenshot();
if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) {
final file = XFile.fromData(
png.buffer.asUint8List(),
mimeType: 'image/png',
);
await SharePlus.instance.share(
ShareParams(
files: [file],
subject: initialTabState.title,
),
);
}
});
}
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
),
WebsiteFeedTile(initialTabState),
],
);
},
),
),
],
);
}
}
@@ -34,8 +34,8 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
@@ -234,7 +234,7 @@ class SearchScreen extends HookConsumerWidget {
child: ClipboardFillLink(controller: searchTextController),
),
const SliverToBoxAdapter(child: Divider()),
SearchTermSuggestions(
FullSearchTermSuggestions(
searchTextController: searchTextController,
activeBang: activeBang,
submitSearch: submitSearch,
@@ -36,6 +36,9 @@ class SearchField extends HookConsumerWidget {
final bool autofocus;
final void Function(String)? onSubmitted;
final bool showSuggestions;
final int? maxLines;
final VoidCallback? onTap;
final bool unfocusOnTapOutside;
final BangData? activeBang;
final bool showBangIcon;
@@ -48,6 +51,9 @@ class SearchField extends HookConsumerWidget {
required this.showSuggestions,
this.label,
this.focusNode,
this.maxLines = 1,
this.onTap,
this.unfocusOnTapOutside = true,
this.showBangIcon = true,
this.autofocus = false,
});
@@ -93,6 +99,11 @@ class SearchField extends HookConsumerWidget {
suggestion: suggestion.value,
enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: safeFocusNode,
maxLines: maxLines,
//Submit isntead of newline
textInputAction: (maxLines == null || maxLines! > 1)
? TextInputAction.done
: null,
autofocus: autofocus,
decoration: InputDecoration(
border: InputBorder.none,
@@ -120,10 +131,13 @@ class SearchField extends HookConsumerWidget {
},
),
),
onTapOutside: (event) {
safeFocusNode.unfocus();
},
onTapOutside: unfocusOnTapOutside
? (event) {
safeFocusNode.unfocus();
}
: null,
onSubmitted: onSubmitted,
onTap: onTap,
);
}
}
@@ -0,0 +1,100 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class FixedSearchTermSuggestions extends HookConsumerWidget {
final TextEditingController searchTextController;
final Future<void> Function(String query) submitSearch;
final BangData? activeBang;
final int count;
const FixedSearchTermSuggestions({
required this.searchTextController,
required this.submitSearch,
required this.activeBang,
this.count = 3,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSuggestableText = useListenableSelector(searchTextController, () {
if (searchTextController.text.isNotEmpty) {
final hasSupportedScheme =
uri_parser
.tryParseUrl(searchTextController.text)
.mapNotNull((uri) => uri.hasSupportedScheme) ??
false;
return !hasSupportedScheme;
}
return false;
});
final searchSuggestions = ref.watch(searchSuggestionsProvider());
useListenableCallback(searchTextController, () {
ref
.read(searchSuggestionsProvider().notifier)
.addQuery(searchTextController.text);
});
final prioritizedSuggestions = isSuggestableText
? [
searchTextController.text,
if (searchSuggestions.valueOrNull != null)
...searchSuggestions.valueOrNull!
.whereNot(
(suggestion) => suggestion == searchTextController.text,
)
.take(count),
]
: <String>[];
return Column(
mainAxisSize: MainAxisSize.min,
children: prioritizedSuggestions
.map(
(suggestion) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search),
title: Text(suggestion),
onLongPress: () {
searchTextController.text = suggestion;
},
onTap: () async {
await submitSearch(suggestion);
},
),
)
.toList(),
);
}
}
@@ -32,20 +32,15 @@ import 'package:weblibre/features/geckoview/features/search/domain/providers/sea
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chips.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
class SearchTermSuggestions extends HookConsumerWidget {
class FullSearchTermSuggestions extends HookConsumerWidget {
final TextEditingController searchTextController;
final Future<void> Function(String query) submitSearch;
final BangData? activeBang;
final bool showHistory;
final bool showChips;
const SearchTermSuggestions({
const FullSearchTermSuggestions({
required this.searchTextController,
required this.submitSearch,
required this.activeBang,
this.showHistory = true,
this.showChips = true,
super.key,
});
@@ -68,9 +63,7 @@ class SearchTermSuggestions extends HookConsumerWidget {
final Widget listSliver;
if (showHistory &&
!searchTextIsNotEmpty &&
(searchHistory.value.isNotEmpty)) {
if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) {
final entries = searchHistory.value!;
listSliver = SliverList.builder(
@@ -128,51 +121,50 @@ class SearchTermSuggestions extends HookConsumerWidget {
return MultiSliver(
children: [
if (showChips)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Search Provider',
style: Theme.of(context).textTheme.labelSmall,
),
BangChips(
activeBang: activeBang,
onSelected: (bang) {
searchTextController.clear();
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Search Provider',
style: Theme.of(context).textTheme.labelSmall,
),
BangChips(
activeBang: activeBang,
onSelected: (bang) {
searchTextController.clear();
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger);
},
onDeleted: (bang) async {
if (ref.read(selectedBangTriggerProvider()) ==
bang.trigger) {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger);
},
onDeleted: (bang) async {
if (ref.read(selectedBangTriggerProvider()) ==
bang.trigger) {
ref
.read(selectedBangTriggerProvider().notifier)
.clearTrigger();
} else {
final dialogResult = await BangChips.resetBangDialog(
context,
bang.trigger,
);
.clearTrigger();
} else {
final dialogResult = await BangChips.resetBangDialog(
context,
bang.trigger,
);
if (dialogResult == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger);
}
if (dialogResult == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger);
}
},
searchTextController: searchTextController,
),
],
),
}
},
searchTextController: searchTextController,
),
],
),
),
),
SliverToBoxAdapter(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
+6 -1
View File
@@ -23,14 +23,19 @@ class Debouncer {
final Duration debounce;
Timer? _timer;
bool _hasRan = false;
Debouncer(this.debounce);
bool get isDebouncing => _timer?.isActive ?? false;
bool get hasRan => _hasRan;
void eventOccured(void Function() callback) {
_timer?.cancel();
_timer = Timer(debounce, callback);
_timer = Timer(debounce, () {
callback.call();
_hasRan = true;
});
}
void dispose() {