refactor tab menu
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
-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/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();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+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/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) {
|
||||
|
||||
-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/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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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/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),
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user