support private tabs

This commit is contained in:
Fabian Freund
2025-04-25 20:41:08 +02:00
parent 9064a961ac
commit f95331c654
15 changed files with 212 additions and 74 deletions
+13 -2
View File
@@ -5,7 +5,10 @@ part of 'routes.dart';
path: '/',
routes: [
TypedGoRoute<WebPageRoute>(name: 'WebPageRoute', path: 'page/:url'),
TypedGoRoute<SearchRoute>(name: 'SearchRoute', path: 'search/:searchText'),
TypedGoRoute<SearchRoute>(
name: 'SearchRoute',
path: 'search/:tabType/:searchText',
),
TypedGoRoute<TorProxyRoute>(name: 'TorProxyRoute', path: 'tor_proxy'),
TypedGoRoute<ContextMenuRoute>(
name: 'ContextMenuRoute',
@@ -50,17 +53,25 @@ class WebPageRoute extends GoRouteData {
}
}
enum TabType { regular, private }
class SearchRoute extends GoRouteData {
static const String emptySearchText = ' ';
final TabType tabType;
//This should be nullable but isnt allowed by go_router
final String searchText;
const SearchRoute({this.searchText = SearchRoute.emptySearchText});
const SearchRoute({
required this.tabType,
this.searchText = SearchRoute.emptySearchText,
});
@override
Widget build(BuildContext context, GoRouterState state) {
return SearchScreen(
tabType: tabType,
initialSearchText:
(searchText.isEmpty || searchText == emptySearchText)
? null
+15 -3
View File
@@ -214,7 +214,7 @@ RouteBase get $browserRoute => GoRouteData.$route(
factory: $WebPageRouteExtension._fromState,
),
GoRouteData.$route(
path: 'search/:searchText',
path: 'search/:tabType/:searchText',
name: 'SearchRoute',
factory: $SearchRouteExtension._fromState,
@@ -292,12 +292,14 @@ extension $WebPageRouteExtension on WebPageRoute {
extension $SearchRouteExtension on SearchRoute {
static SearchRoute _fromState(GoRouterState state) => SearchRoute(
tabType: _$TabTypeEnumMap._$fromName(state.pathParameters['tabType']!)!,
searchText:
state.pathParameters['searchText'] ?? SearchRoute.emptySearchText,
);
String get location =>
GoRouteData.$location('/search/${Uri.encodeComponent(searchText)}');
String get location => GoRouteData.$location(
'/search/${Uri.encodeComponent(_$TabTypeEnumMap[tabType]!)}/${Uri.encodeComponent(searchText)}',
);
void go(BuildContext context) => context.go(location);
@@ -309,6 +311,11 @@ extension $SearchRouteExtension on SearchRoute {
void replace(BuildContext context) => context.replace(location);
}
const _$TabTypeEnumMap = {
TabType.regular: 'regular',
TabType.private: 'private',
};
extension $TorProxyRouteExtension on TorProxyRoute {
static TorProxyRoute _fromState(GoRouterState state) => TorProxyRoute();
@@ -394,6 +401,11 @@ extension $ContainerEditRouteExtension on ContainerEditRoute {
context.replace(location, extra: $extra);
}
extension<T extends Enum> on Map<T, String> {
T? _$fromName(String? value) =>
entries.where((element) => element.value == value).firstOrNull?.key;
}
RouteBase get $bangCategoriesRoute => GoRouteData.$route(
path: '/bangs',
name: 'BangRoute',
@@ -41,7 +41,7 @@ class BangListScreen extends HookConsumerWidget {
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger);
const SearchRoute().go(context);
const SearchRoute(tabType: TabType.regular).go(context);
},
);
},
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/providers/search.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
import 'package:lensai/features/geckoview/features/search/domain/providers/search_suggestions.dart';
@@ -45,12 +46,21 @@ class SiteSearch extends HookConsumerWidget {
Future<void> submitSearch(String query) async {
if (activeBang != null && (formKey.currentState?.validate() == true)) {
final searchUri = await ref
.read(bangSearchProvider.notifier)
.triggerBangSearch(activeBang, query);
final isPrivate =
ref.read(selectedTabStateProvider)?.isPrivate ?? false;
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);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: searchUri, private: isPrivate);
} else {
await ref
.read(tabSessionProvider(tabId: null).notifier)
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/core/logger.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -24,13 +24,14 @@ GeckoSelectionActionService selectionActionService(Ref ref) {
);
if (defaultSearchBang != null) {
final currentTabId = ref.read(selectedTabProvider);
final currentTab = ref.read(selectedTabStateProvider);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: defaultSearchBang.getTemplateUrl(text),
parentId: currentTabId,
parentId: currentTab?.id,
private: currentTab?.isPrivate ?? false,
);
} else {
logger.e('No default search bang found');
@@ -50,7 +51,7 @@ GeckoSelectionActionService selectionActionService(Ref ref) {
// }
// }),
ShareAction((text) async {
await Share.share(text);
await SharePlus.instance.share(ShareParams(text: text));
}),
CallAction((text) async {
final uri = Uri.tryParse('tel:${text.replaceAll(' ', '')}');
@@ -7,7 +7,7 @@ part of 'providers.dart';
// **************************************************************************
String _$selectionActionServiceHash() =>
r'd907a3c5ab7efde82bb2cc1fb0409fd3fd13bb25';
r'fc7344fa34f0e27772309303083804866336197b';
/// See also [selectionActionService].
@ProviderFor(selectionActionService)
@@ -161,6 +161,31 @@ class WebPageDialog extends HookConsumerWidget {
}
},
),
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: () {
repo.selectTab(tabId);
},
);
context.pop();
}
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share link'),
@@ -106,7 +106,7 @@ class BrowserBottomAppBar extends HookConsumerWidget {
return child!;
},
menuChildren: [
if (selectedTabId != null)
if (selectedTabId != null) ...[
MenuItemButton(
onPressed: () async {
await ref
@@ -116,11 +116,24 @@ class BrowserBottomAppBar extends HookConsumerWidget {
leadingIcon: const Icon(Icons.close),
child: const Text('Close Tab'),
),
const Divider(),
],
MenuItemButton(
onPressed: () async {
await const SearchRoute().push(context);
await const SearchRoute(
tabType: TabType.private,
).push(context);
},
leadingIcon: const Icon(Icons.add),
leadingIcon: const Icon(MdiIcons.tabUnselected),
child: const Text('Add Private Tab'),
),
MenuItemButton(
onPressed: () async {
await const SearchRoute(
tabType: TabType.regular,
).push(context);
},
leadingIcon: const Icon(MdiIcons.tabPlus),
child: const Text('Add Tab'),
),
],
@@ -210,7 +210,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
showSuggestNewTabMessage(
context,
onAdd: () async {
await const SearchRoute().push(context);
await const SearchRoute(tabType: TabType.regular).push(context);
},
);
}
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_reorderable_grid_view/widgets/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/providers/global_drop.dart';
@@ -62,7 +63,7 @@ class _TabSheetHeader extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
icon: const Icon(Icons.search),
icon: const Icon(MdiIcons.tabSearch),
label: const Text('Search'),
onPressed: () {
searchMode.value = true;
@@ -410,7 +411,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
),
child: FloatingActionButton.small(
onPressed: () async {
await const SearchRoute().push(context);
await const SearchRoute(tabType: TabType.regular).push(context);
onClose();
},
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/entities/states/tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
@@ -34,10 +35,13 @@ class TabPreview extends StatelessWidget {
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
),
child: Material(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
color:
tab.isPrivate
? const Color(0xFF25003E)
: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(14.0)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
borderRadius: const BorderRadius.all(Radius.circular(14.0)),
onTap: onTap,
onDoubleTap: onDoubleTap,
child: Column(
@@ -46,11 +50,15 @@ class TabPreview extends StatelessWidget {
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6.0),
padding: const EdgeInsets.only(left: 6.0, top: 2.0),
child: Text(
overflow: TextOverflow.ellipsis,
tab.title,
maxLines: 2,
style:
tab.isPrivate
? const TextStyle(color: Colors.white)
: null,
),
),
),
@@ -72,9 +80,16 @@ class TabPreview extends StatelessWidget {
Expanded(
child: Text(
tab.url.authority,
style: Theme.of(context).textTheme.bodySmall,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tab.isPrivate ? Colors.white : null,
),
),
),
if (tab.isPrivate) ...[
const SizedBox(width: 6.0),
const Icon(MdiIcons.dominoMask, color: Color(0xFF8000D7)),
],
const SizedBox(width: 8.0),
],
),
const SizedBox(height: 6),
@@ -82,8 +97,8 @@ class TabPreview extends StatelessWidget {
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16.0),
bottomRight: Radius.circular(16.0),
bottomLeft: Radius.circular(14.0),
bottomRight: Radius.circular(14.0),
),
child: SizedBox(
width: double.infinity,
@@ -3,7 +3,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:lensai/utils/ui_helper.dart';
@@ -23,14 +23,15 @@ class OpenImageInNewTab extends HookConsumerWidget {
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Open image in new tab'),
onTap: () async {
final currentTabId = ref.read(selectedTabProvider);
final currentTab = ref.read(selectedTabStateProvider);
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: hitResult.tryGetLink(),
parentId: currentTabId,
parentId: currentTab?.id,
selectTab: false,
private: currentTab?.isPrivate ?? false,
);
if (context.mounted) {
@@ -3,7 +3,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:lensai/utils/ui_helper.dart';
@@ -23,14 +23,15 @@ class OpenInNewTab extends HookConsumerWidget {
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Open in new tab'),
onTap: () async {
final currentTabId = ref.read(selectedTabProvider);
final currentTab = ref.read(selectedTabStateProvider);
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: hitResult.tryGetLink(),
parentId: currentTabId,
parentId: currentTab?.id,
selectTab: false,
private: currentTab?.isPrivate ?? false,
);
if (context.mounted) {
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/domain/providers/search.dart';
@@ -18,13 +20,17 @@ import 'package:lensai/utils/uri_parser.dart' as uri_parser;
class SearchScreen extends HookConsumerWidget {
final String? initialSearchText;
final TabType tabType;
const SearchScreen({required this.initialSearchText});
const SearchScreen({required this.initialSearchText, required this.tabType});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final selectedTabType = useState(tabType);
final isPrivate = selectedTabType.value == TabType.private;
final searchTextController = useTextEditingController(
text: initialSearchText,
);
@@ -52,11 +58,17 @@ class SearchScreen extends HookConsumerWidget {
Future<void> submitSearch(String query) async {
if (activeBang != null && (formKey.currentState?.validate() == true)) {
final searchUri = await ref
.read(bangSearchProvider.notifier)
.triggerBangSearch(activeBang, query);
final searchUri = activeBang.getTemplateUrl(query);
await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri);
if (!isPrivate) {
await ref
.read(bangSearchProvider.notifier)
.triggerBangSearch(activeBang, query);
}
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: searchUri, private: isPrivate);
if (context.mounted) {
ref.read(bottomSheetControllerProvider.notifier).dismiss();
@@ -75,43 +87,72 @@ class SearchScreen extends HookConsumerWidget {
floating: true,
pinned: true,
automaticallyImplyLeading: false,
title: SearchField(
showBangIcon: showBangIcon.value,
textEditingController: searchTextController,
focusNode: searchFocusNode,
autofocus: true,
onSubmitted: (value) async {
if (value.isNotEmpty) {
var newUrl = uri_parser.tryParseUrl(
value,
eagerParsing: true,
);
title: Align(
child: SegmentedButton(
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: TabType.regular,
label: Text('Regular'),
icon: Icon(MdiIcons.tab),
),
ButtonSegment(
value: TabType.private,
label: Text('Private'),
icon: Icon(MdiIcons.tabUnselected),
),
],
selected: {selectedTabType.value},
onSelectionChanged: (value) {
selectedTabType.value = value.first;
},
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: SearchField(
showBangIcon: showBangIcon.value,
textEditingController: searchTextController,
focusNode: searchFocusNode,
autofocus: true,
onSubmitted: (value) async {
if (value.isNotEmpty) {
var newUrl = uri_parser.tryParseUrl(
value,
eagerParsing: true,
);
if (newUrl == null) {
final defaultSearchBang =
ref.read(selectedBangDataProvider()) ??
await ref.read(defaultSearchBangDataProvider.future);
if (newUrl == null) {
final defaultSearchBang =
ref.read(selectedBangDataProvider()) ??
await ref.read(
defaultSearchBangDataProvider.future,
);
newUrl = defaultSearchBang?.getTemplateUrl(value);
}
newUrl = defaultSearchBang?.getTemplateUrl(value);
}
if (newUrl != null) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: newUrl);
if (newUrl != null) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: newUrl, private: isPrivate);
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
context.pop();
context.pop();
}
}
}
}
}
},
activeBang: activeBang,
showSuggestions: true,
},
activeBang: activeBang,
showSuggestions: true,
),
),
),
),
const SliverToBoxAdapter(child: Divider()),
@@ -120,10 +161,12 @@ class SearchScreen extends HookConsumerWidget {
activeBang: activeBang,
submitSearch: submitSearch,
),
const SliverToBoxAdapter(child: Divider()),
TabSearch(searchTextListenable: sampledSearchText),
FeedSearch(searchTextNotifier: sampledSearchText),
HistorySuggestions(searchTextListenable: sampledSearchText),
HistorySuggestions(
isPrivate: isPrivate,
searchTextListenable: sampledSearchText,
),
],
),
),
@@ -15,9 +15,14 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:sliver_tools/sliver_tools.dart';
class HistorySuggestions extends HookConsumerWidget {
final bool isPrivate;
final ValueListenable<TextEditingValue> searchTextListenable;
const HistorySuggestions({super.key, required this.searchTextListenable});
const HistorySuggestions({
super.key,
required this.isPrivate,
required this.searchTextListenable,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -89,7 +94,7 @@ class HistorySuggestions extends HookConsumerWidget {
case final Uri url) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: url);
.addTab(url: url, private: isPrivate);
if (context.mounted) {
ref