refactor tab menu
This commit is contained in:
@@ -23,7 +23,6 @@ part of 'routes.dart';
|
|||||||
name: BrowserRoute.name,
|
name: BrowserRoute.name,
|
||||||
path: '/',
|
path: '/',
|
||||||
routes: [
|
routes: [
|
||||||
TypedGoRoute<WebPageRoute>(name: 'WebPageRoute', path: 'page/:url'),
|
|
||||||
TypedGoRoute<SearchRoute>(
|
TypedGoRoute<SearchRoute>(
|
||||||
name: 'SearchRoute',
|
name: 'SearchRoute',
|
||||||
path: 'search/:tabType/:searchText',
|
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 }
|
enum TabType { regular, private, child }
|
||||||
|
|
||||||
class SearchRoute extends GoRouteData with _$SearchRoute {
|
class SearchRoute extends GoRouteData with _$SearchRoute {
|
||||||
|
|||||||
@@ -22,13 +22,11 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:weblibre/core/routing/widgets/dialog_page.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/about/presentation/screens/about.dart';
|
||||||
import 'package:weblibre/features/bangs/presentation/screens/categories.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/list.dart';
|
||||||
import 'package:weblibre/features/bangs/presentation/screens/search.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/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/browser/presentation/screens/browser.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
|
||||||
|
|||||||
@@ -283,12 +283,6 @@ RouteBase get $browserRoute => GoRouteData.$route(
|
|||||||
|
|
||||||
factory: _$BrowserRoute._fromState,
|
factory: _$BrowserRoute._fromState,
|
||||||
routes: [
|
routes: [
|
||||||
GoRouteData.$route(
|
|
||||||
path: 'page/:url',
|
|
||||||
name: 'WebPageRoute',
|
|
||||||
|
|
||||||
factory: _$WebPageRoute._fromState,
|
|
||||||
),
|
|
||||||
GoRouteData.$route(
|
GoRouteData.$route(
|
||||||
path: 'search/:tabType/:searchText',
|
path: 'search/:tabType/:searchText',
|
||||||
name: 'SearchRoute',
|
name: 'SearchRoute',
|
||||||
@@ -362,34 +356,6 @@ mixin _$BrowserRoute on GoRouteData {
|
|||||||
void replace(BuildContext context) => context.replace(location);
|
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 {
|
mixin _$SearchRoute on GoRouteData {
|
||||||
static SearchRoute _fromState(GoRouterState state) => SearchRoute(
|
static SearchRoute _fromState(GoRouterState state) => SearchRoute(
|
||||||
tabType: _$TabTypeEnumMap._$fromName(state.pathParameters['tabType']!)!,
|
tabType: _$TabTypeEnumMap._$fromName(state.pathParameters['tabType']!)!,
|
||||||
|
|||||||
@@ -17,41 +17,45 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* 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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/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_session.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.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/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_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/hooks/listenable_callback.dart';
|
||||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
|
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||||
|
|
||||||
class SiteSearch extends HookConsumerWidget {
|
class SiteSearch extends HookConsumerWidget {
|
||||||
final String domain;
|
final String domain;
|
||||||
final List<BangData> availableBangs;
|
final List<BangData> availableBangs;
|
||||||
|
final String? initialText;
|
||||||
final bool searchInNewTab;
|
final bool searchInNewTab;
|
||||||
|
|
||||||
const SiteSearch({
|
const SiteSearch({
|
||||||
required this.domain,
|
required this.domain,
|
||||||
required this.availableBangs,
|
required this.availableBangs,
|
||||||
super.key,
|
super.key,
|
||||||
this.searchInNewTab = true,
|
this.initialText,
|
||||||
|
this.searchInNewTab = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
final searchTextController = useTextEditingController(text: initialText);
|
||||||
|
final searchFocusNode = useFocusNode();
|
||||||
final searchTextController = useTextEditingController();
|
|
||||||
|
|
||||||
useListenableCallback(searchTextController, () {
|
useListenableCallback(searchTextController, () {
|
||||||
ref
|
ref
|
||||||
@@ -60,44 +64,55 @@ class SiteSearch extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final selectedBang = ref.watch(selectedBangDataProvider(domain: domain));
|
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 {
|
final newUrl = uri_parser.tryParseUrl(value, eagerParsing: true);
|
||||||
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
|
||||||
final isPrivate =
|
|
||||||
ref.read(selectedTabStateProvider)?.isPrivate ?? false;
|
|
||||||
|
|
||||||
final searchUri = activeBang.getTemplateUrl(query);
|
if (newUrl != null) {
|
||||||
|
|
||||||
if (!isPrivate) {
|
|
||||||
await ref
|
|
||||||
.read(bangSearchProvider.notifier)
|
|
||||||
.triggerBangSearch(activeBang, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchInNewTab) {
|
|
||||||
await ref
|
|
||||||
.read(tabRepositoryProvider.notifier)
|
|
||||||
.addTab(url: searchUri, private: isPrivate);
|
|
||||||
} else {
|
|
||||||
await ref
|
await ref
|
||||||
.read(tabSessionProvider(tabId: null).notifier)
|
.read(tabSessionProvider(tabId: null).notifier)
|
||||||
.loadUrl(url: searchUri);
|
.loadUrl(url: newUrl);
|
||||||
}
|
|
||||||
|
|
||||||
if (context.mounted) {
|
ref.read(bottomSheetControllerProvider.notifier).dismiss();
|
||||||
context.pop();
|
} 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(
|
return Column(
|
||||||
key: formKey,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Visibility(
|
||||||
children: [
|
visible: availableBangs.isNotEmpty,
|
||||||
SizedBox(
|
child: SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
child: SelectableChips(
|
child: SelectableChips(
|
||||||
@@ -109,6 +124,18 @@ class SiteSearch extends HookConsumerWidget {
|
|||||||
availableItems: availableBangs,
|
availableItems: availableBangs,
|
||||||
selectedItem: selectedBang,
|
selectedItem: selectedBang,
|
||||||
onSelected: (bang) {
|
onSelected: (bang) {
|
||||||
|
if (selectedBang == null) {
|
||||||
|
final hasSupportedScheme =
|
||||||
|
uri_parser
|
||||||
|
.tryParseUrl(searchTextController.text)
|
||||||
|
.mapNotNull((uri) => uri.hasSupportedScheme) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (hasSupportedScheme) {
|
||||||
|
searchTextController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(selectedBangTriggerProvider(domain: domain).notifier)
|
.read(selectedBangTriggerProvider(domain: domain).notifier)
|
||||||
.setTrigger(bang.trigger);
|
.setTrigger(bang.trigger);
|
||||||
@@ -125,37 +152,33 @@ class SiteSearch extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SearchField(
|
),
|
||||||
textEditingController: searchTextController,
|
SearchField(
|
||||||
activeBang: activeBang,
|
textEditingController: searchTextController,
|
||||||
onSubmitted: (_) async {
|
focusNode: searchFocusNode,
|
||||||
await submitSearch(searchTextController.text);
|
maxLines: null,
|
||||||
},
|
activeBang: selectedBang,
|
||||||
showSuggestions: false,
|
showSuggestions: true,
|
||||||
),
|
onTap: () {
|
||||||
ConstrainedBox(
|
if (!searchFocusNode.hasFocus) {
|
||||||
constraints: const BoxConstraints(maxHeight: 150),
|
// Select all text when the field is tapped
|
||||||
child: FadingScroll(
|
searchTextController.selection = TextSelection(
|
||||||
fadingSize: 25,
|
baseOffset: 0,
|
||||||
builder: (context, controller) {
|
extentOffset: searchTextController.text.length,
|
||||||
return CustomScrollView(
|
);
|
||||||
shrinkWrap: true,
|
}
|
||||||
controller: controller,
|
},
|
||||||
slivers: [
|
unfocusOnTapOutside: false,
|
||||||
SearchTermSuggestions(
|
onSubmitted: (value) async {
|
||||||
searchTextController: searchTextController,
|
await submitSearch(value);
|
||||||
activeBang: activeBang,
|
},
|
||||||
submitSearch: submitSearch,
|
),
|
||||||
showHistory: false,
|
FixedSearchTermSuggestions(
|
||||||
showChips: false,
|
searchTextController: searchTextController,
|
||||||
),
|
activeBang: selectedBang,
|
||||||
],
|
submitSearch: submitSearch,
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
|
|
||||||
sealed class Sheet with FastEquatable {}
|
sealed class Sheet with FastEquatable {}
|
||||||
|
|
||||||
@@ -26,11 +27,11 @@ final class ViewTabsSheet extends Sheet {
|
|||||||
List<Object?> get hashParameters => [null];
|
List<Object?> get hashParameters => [null];
|
||||||
}
|
}
|
||||||
|
|
||||||
final class TabQaChatSheet extends Sheet {
|
final class EditUrlSheet extends Sheet {
|
||||||
final String chatId;
|
final TabState tabState;
|
||||||
|
|
||||||
TabQaChatSheet({required this.chatId});
|
EditUrlSheet({required this.tabState});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [chatId];
|
List<Object?> get hashParameters => [tabState];
|
||||||
}
|
}
|
||||||
|
|||||||
-350
@@ -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/extensions/media_query.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/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.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_list.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/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/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/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/browser/presentation/widgets/sheets/view_tabs.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.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';
|
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),
|
key: ValueKey(displayedSheet),
|
||||||
maxChildSize: MediaQuery.of(context).relativeSafeArea(),
|
maxChildSize: MediaQuery.of(context).relativeSafeArea(),
|
||||||
),
|
),
|
||||||
final TabQaChatSheet parameter => _QaSheet(
|
final EditUrlSheet parameter => _ViewUrlSheet(
|
||||||
key: ValueKey(displayedSheet),
|
key: ValueKey(displayedSheet),
|
||||||
|
initialTabState: parameter.tabState,
|
||||||
maxChildSize: MediaQuery.of(context).relativeSafeArea(),
|
maxChildSize: MediaQuery.of(context).relativeSafeArea(),
|
||||||
chatId: parameter.chatId,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -353,11 +354,15 @@ class _BrowserView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QaSheet extends HookConsumerWidget {
|
class _ViewUrlSheet extends HookConsumerWidget {
|
||||||
final String chatId;
|
|
||||||
final double maxChildSize;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -366,6 +371,7 @@ class _QaSheet extends HookConsumerWidget {
|
|||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
controller: draggableScrollableController,
|
controller: draggableScrollableController,
|
||||||
expand: false,
|
expand: false,
|
||||||
|
initialChildSize: 172.0 / MediaQuery.of(context).size.height,
|
||||||
minChildSize: 0.1,
|
minChildSize: 0.1,
|
||||||
maxChildSize: maxChildSize,
|
maxChildSize: maxChildSize,
|
||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
@@ -374,28 +380,13 @@ class _QaSheet extends HookConsumerWidget {
|
|||||||
topLeft: Radius.circular(28),
|
topLeft: Radius.circular(28),
|
||||||
topRight: Radius.circular(28),
|
topRight: Radius.circular(28),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: ViewTabSheetWidget(
|
||||||
children: [
|
initialTabState: initialTabState,
|
||||||
DraggableScrollableHeader(
|
sheetScrollController: scrollController,
|
||||||
controller: draggableScrollableController,
|
draggableScrollableController: draggableScrollableController,
|
||||||
child: Material(
|
onClose: () {
|
||||||
child: SizedBox(
|
ref.read(bottomSheetControllerProvider.notifier).dismiss();
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-22
@@ -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/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.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/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_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/extension_shortcut_menu.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_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,
|
height: AppBar().preferredSize.height,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () async {
|
onTap: () {
|
||||||
final tabState = ref.read(selectedTabStateProvider);
|
final tabState = ref.read(selectedTabStateProvider);
|
||||||
|
|
||||||
if (tabState != null) {
|
if (tabState != null) {
|
||||||
await WebPageRoute(
|
ref
|
||||||
url: tabState.url.toString(),
|
.read(bottomSheetControllerProvider.notifier)
|
||||||
$extra: tabState,
|
.show(EditUrlSheet(tabState: 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
|
|||||||
-125
@@ -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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+378
@@ -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/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_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/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/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/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
|
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
|
||||||
@@ -234,7 +234,7 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
child: ClipboardFillLink(controller: searchTextController),
|
child: ClipboardFillLink(controller: searchTextController),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: Divider()),
|
const SliverToBoxAdapter(child: Divider()),
|
||||||
SearchTermSuggestions(
|
FullSearchTermSuggestions(
|
||||||
searchTextController: searchTextController,
|
searchTextController: searchTextController,
|
||||||
activeBang: activeBang,
|
activeBang: activeBang,
|
||||||
submitSearch: submitSearch,
|
submitSearch: submitSearch,
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class SearchField extends HookConsumerWidget {
|
|||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
final void Function(String)? onSubmitted;
|
final void Function(String)? onSubmitted;
|
||||||
final bool showSuggestions;
|
final bool showSuggestions;
|
||||||
|
final int? maxLines;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool unfocusOnTapOutside;
|
||||||
|
|
||||||
final BangData? activeBang;
|
final BangData? activeBang;
|
||||||
final bool showBangIcon;
|
final bool showBangIcon;
|
||||||
@@ -48,6 +51,9 @@ class SearchField extends HookConsumerWidget {
|
|||||||
required this.showSuggestions,
|
required this.showSuggestions,
|
||||||
this.label,
|
this.label,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
|
this.maxLines = 1,
|
||||||
|
this.onTap,
|
||||||
|
this.unfocusOnTapOutside = true,
|
||||||
this.showBangIcon = true,
|
this.showBangIcon = true,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
});
|
});
|
||||||
@@ -93,6 +99,11 @@ class SearchField extends HookConsumerWidget {
|
|||||||
suggestion: suggestion.value,
|
suggestion: suggestion.value,
|
||||||
enableIMEPersonalizedLearning: !incognitoEnabled,
|
enableIMEPersonalizedLearning: !incognitoEnabled,
|
||||||
focusNode: safeFocusNode,
|
focusNode: safeFocusNode,
|
||||||
|
maxLines: maxLines,
|
||||||
|
//Submit isntead of newline
|
||||||
|
textInputAction: (maxLines == null || maxLines! > 1)
|
||||||
|
? TextInputAction.done
|
||||||
|
: null,
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
@@ -120,10 +131,13 @@ class SearchField extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTapOutside: (event) {
|
onTapOutside: unfocusOnTapOutside
|
||||||
safeFocusNode.unfocus();
|
? (event) {
|
||||||
},
|
safeFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
onSubmitted: onSubmitted,
|
onSubmitted: onSubmitted,
|
||||||
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-48
@@ -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/features/geckoview/features/search/presentation/widgets/bang_chips.dart';
|
||||||
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
|
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
|
||||||
|
|
||||||
class SearchTermSuggestions extends HookConsumerWidget {
|
class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||||
final TextEditingController searchTextController;
|
final TextEditingController searchTextController;
|
||||||
final Future<void> Function(String query) submitSearch;
|
final Future<void> Function(String query) submitSearch;
|
||||||
final BangData? activeBang;
|
final BangData? activeBang;
|
||||||
|
|
||||||
final bool showHistory;
|
const FullSearchTermSuggestions({
|
||||||
final bool showChips;
|
|
||||||
|
|
||||||
const SearchTermSuggestions({
|
|
||||||
required this.searchTextController,
|
required this.searchTextController,
|
||||||
required this.submitSearch,
|
required this.submitSearch,
|
||||||
required this.activeBang,
|
required this.activeBang,
|
||||||
this.showHistory = true,
|
|
||||||
this.showChips = true,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,9 +63,7 @@ class SearchTermSuggestions extends HookConsumerWidget {
|
|||||||
|
|
||||||
final Widget listSliver;
|
final Widget listSliver;
|
||||||
|
|
||||||
if (showHistory &&
|
if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) {
|
||||||
!searchTextIsNotEmpty &&
|
|
||||||
(searchHistory.value.isNotEmpty)) {
|
|
||||||
final entries = searchHistory.value!;
|
final entries = searchHistory.value!;
|
||||||
|
|
||||||
listSliver = SliverList.builder(
|
listSliver = SliverList.builder(
|
||||||
@@ -128,51 +121,50 @@ class SearchTermSuggestions extends HookConsumerWidget {
|
|||||||
|
|
||||||
return MultiSliver(
|
return MultiSliver(
|
||||||
children: [
|
children: [
|
||||||
if (showChips)
|
SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
'Search Provider',
|
||||||
'Search Provider',
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
),
|
||||||
),
|
BangChips(
|
||||||
BangChips(
|
activeBang: activeBang,
|
||||||
activeBang: activeBang,
|
onSelected: (bang) {
|
||||||
onSelected: (bang) {
|
searchTextController.clear();
|
||||||
searchTextController.clear();
|
|
||||||
|
|
||||||
|
ref
|
||||||
|
.read(selectedBangTriggerProvider().notifier)
|
||||||
|
.setTrigger(bang.trigger);
|
||||||
|
},
|
||||||
|
onDeleted: (bang) async {
|
||||||
|
if (ref.read(selectedBangTriggerProvider()) ==
|
||||||
|
bang.trigger) {
|
||||||
ref
|
ref
|
||||||
.read(selectedBangTriggerProvider().notifier)
|
.read(selectedBangTriggerProvider().notifier)
|
||||||
.setTrigger(bang.trigger);
|
.clearTrigger();
|
||||||
},
|
} else {
|
||||||
onDeleted: (bang) async {
|
final dialogResult = await BangChips.resetBangDialog(
|
||||||
if (ref.read(selectedBangTriggerProvider()) ==
|
context,
|
||||||
bang.trigger) {
|
bang.trigger,
|
||||||
ref
|
);
|
||||||
.read(selectedBangTriggerProvider().notifier)
|
|
||||||
.clearTrigger();
|
|
||||||
} else {
|
|
||||||
final dialogResult = await BangChips.resetBangDialog(
|
|
||||||
context,
|
|
||||||
bang.trigger,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dialogResult == true) {
|
if (dialogResult == true) {
|
||||||
await ref
|
await ref
|
||||||
.read(bangDataRepositoryProvider.notifier)
|
.read(bangDataRepositoryProvider.notifier)
|
||||||
.resetFrequency(bang.trigger);
|
.resetFrequency(bang.trigger);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
searchTextController: searchTextController,
|
},
|
||||||
),
|
searchTextController: searchTextController,
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 150),
|
constraints: const BoxConstraints(maxHeight: 150),
|
||||||
@@ -23,14 +23,19 @@ class Debouncer {
|
|||||||
final Duration debounce;
|
final Duration debounce;
|
||||||
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
bool _hasRan = false;
|
||||||
|
|
||||||
Debouncer(this.debounce);
|
Debouncer(this.debounce);
|
||||||
|
|
||||||
bool get isDebouncing => _timer?.isActive ?? false;
|
bool get isDebouncing => _timer?.isActive ?? false;
|
||||||
|
bool get hasRan => _hasRan;
|
||||||
|
|
||||||
void eventOccured(void Function() callback) {
|
void eventOccured(void Function() callback) {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer(debounce, callback);
|
_timer = Timer(debounce, () {
|
||||||
|
callback.call();
|
||||||
|
_hasRan = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
Reference in New Issue
Block a user