added context menu support

This commit is contained in:
Fabian Freund
2025-02-23 17:11:28 +01:00
parent a5133b0b90
commit cbfd218c7e
30 changed files with 2159 additions and 159 deletions
+18
View File
@@ -7,6 +7,10 @@ part of 'routes.dart';
TypedGoRoute<WebPageRoute>(name: 'WebPageRoute', path: 'page/:url'),
TypedGoRoute<SearchRoute>(name: 'SearchRoute', path: 'search/:searchText'),
TypedGoRoute<TorProxyRoute>(name: 'TorProxyRoute', path: 'tor_proxy'),
TypedGoRoute<ContextMenuRoute>(
name: 'ContextMenuRoute',
path: 'context_menu',
),
TypedGoRoute<ContainerListRoute>(
name: 'ContainerListRoute',
path: 'containers',
@@ -97,3 +101,17 @@ class ContainerCreateRoute extends GoRouteData {
);
}
}
class ContextMenuRoute extends GoRouteData {
const ContextMenuRoute();
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(
builder:
(_) => ContextMenuDialog(
hitResult: HitResultJson.fromJson(state.extra! as String),
),
);
}
}
+2
View File
@@ -8,6 +8,8 @@ import 'package:lensai/features/bangs/presentation/screens/list.dart';
import 'package:lensai/features/bangs/presentation/screens/search.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/screens/browser.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
import 'package:lensai/features/geckoview/features/search/presentation/screens/search.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:lensai/features/geckoview/features/tabs/presentation/screens/container_edit.dart';
+23
View File
@@ -227,6 +227,11 @@ RouteBase get $browserRoute => GoRouteData.$route(
name: 'TorProxyRoute',
factory: $TorProxyRouteExtension._fromState,
),
GoRouteData.$route(
path: 'context_menu',
name: 'ContextMenuRoute',
factory: $ContextMenuRouteExtension._fromState,
),
GoRouteData.$route(
path: 'containers',
name: 'ContainerListRoute',
@@ -320,6 +325,24 @@ extension $TorProxyRouteExtension on TorProxyRoute {
void replace(BuildContext context) => context.replace(location);
}
extension $ContextMenuRouteExtension on ContextMenuRoute {
static ContextMenuRoute _fromState(GoRouterState state) =>
const ContextMenuRoute();
String get location => GoRouteData.$location(
'/context_menu',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}
extension $ContainerListRouteExtension on ContainerListRoute {
static ContainerListRoute _fromState(GoRouterState state) =>
ContainerListRoute();
@@ -8,6 +8,7 @@ import 'package:lensai/data/models/drag_data.dart';
import 'package:lensai/extensions/media_query.dart';
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:lensai/features/geckoview/domain/controllers/overlay.dart';
import 'package:lensai/features/geckoview/domain/providers.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_list.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
@@ -17,6 +18,7 @@ import 'package:lensai/features/geckoview/features/browser/presentation/widgets/
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:lensai/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart';
import 'package:lensai/features/geckoview/features/tabs/features/chat/presentation/widgets/tab_qa_chat.dart';
@@ -29,6 +31,8 @@ class BrowserScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final eventService = ref.watch(eventServiceProvider);
final displayedSheet = ref.watch(bottomSheetControllerProvider);
final displayedOverlay = ref.watch(overlayControllerProvider);
@@ -48,6 +52,16 @@ class BrowserScreen extends HookConsumerWidget {
}
});
useOnStreamChange(
eventService.longPressEvent,
onData: (event) async {
await context.push(
const ContextMenuRoute().location,
extra: event.hitResult.toJson(),
);
},
);
return PopScope(
//We need this for BackButtonListener to work downstream
//No direct pop result will be handled here
@@ -0,0 +1,45 @@
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
class HitResultConverter {
const HitResultConverter();
Map<String, dynamic> encode(HitResult input) {
final (type, data) = switch (input) {
final UnknownHitResult result => (result.runtimeType, result.encode()),
final ImageHitResult result => (result.runtimeType, result.encode()),
final VideoHitResult result => (result.runtimeType, result.encode()),
final AudioHitResult result => (result.runtimeType, result.encode()),
final ImageSrcHitResult result => (result.runtimeType, result.encode()),
final PhoneHitResult result => (result.runtimeType, result.encode()),
final EmailHitResult result => (result.runtimeType, result.encode()),
final GeoHitResult result => (result.runtimeType, result.encode()),
};
return {'type': type.toString(), 'data': data};
}
HitResult decode(Map<String, dynamic> input) {
final {'type': String type, 'data': List<Object?> data} = input;
switch (type) {
case 'UnknownHitResult':
return UnknownHitResult.decode(data);
case 'ImageHitResult':
return ImageHitResult.decode(data);
case 'VideoHitResult':
return VideoHitResult.decode(data);
case 'AudioHitResult':
return AudioHitResult.decode(data);
case 'ImageSrcHitResult':
return ImageSrcHitResult.decode(data);
case 'PhoneHitResult':
return PhoneHitResult.decode(data);
case 'EmailHitResult':
return EmailHitResult.decode(data);
case 'GeoHitResult':
return GeoHitResult.decode(data);
default:
throw FormatException('Unknown HitResult type: $type');
}
}
}
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/features/geckoview/features/contextmenu/domain/converters/hit_result_converter.dart';
extension HitResultJson on HitResult {
String toJson() {
const codec = HitResultConverter();
return jsonEncode(codec.encode(this));
}
static HitResult fromJson(String json) {
const codec = HitResultConverter();
return codec.decode(jsonDecode(json) as Map<String, dynamic>);
}
}
extension HitResultX on HitResult {
Uri? tryGetLink() {
final uri = switch (this) {
UnknownHitResult(src: final src) => src,
ImageHitResult(src: final src) => src,
VideoHitResult(src: final src) => src,
AudioHitResult(src: final src) => src,
ImageSrcHitResult(src: final src) => src,
PhoneHitResult(src: final src) => src,
EmailHitResult(src: final src) => src,
GeoHitResult(src: final src) => src,
};
return Uri.tryParse(uri);
}
String getTitle() {
const maxTitleLength = 2500;
return switch (this) {
UnknownHitResult(src: final src) => src,
ImageSrcHitResult(uri: final uri) => uri,
ImageHitResult(src: final src, title: final title) =>
title?.isEmpty ?? true
? (src.length > maxTitleLength ? 'image' : src)
: title!,
VideoHitResult(src: final src, title: final title) =>
(title?.isEmpty ?? true) ? src : title!,
AudioHitResult(src: final src, title: final title) =>
(title?.isEmpty ?? true) ? src : title!,
_ => 'about:blank',
};
}
bool get hasSrc => tryGetLink() != null;
bool isImage() {
return (this is ImageHitResult || this is ImageSrcHitResult) && hasSrc;
}
bool isVideoAudio() {
return (this is VideoHitResult || this is AudioHitResult) && hasSrc;
}
bool isFile() {
return this is UnknownHitResult && hasSrc;
}
bool isUri() {
return (this is UnknownHitResult && hasSrc) || this is ImageSrcHitResult;
}
bool isHttpLink() {
return isUri() &&
switch (tryGetLink()?.scheme) {
'http' => true,
'https' => true,
_ => false,
};
}
bool isIntent() {
return this is UnknownHitResult &&
hasSrc &&
tryGetLink()?.scheme == 'intent';
}
bool isMailto() {
return this is UnknownHitResult &&
hasSrc &&
tryGetLink()?.scheme == 'mailto';
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class CopyEmail extends HookConsumerWidget {
final HitResult hitResult;
const CopyEmail({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isMailto();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.emailMultiple),
title: const Text('Share email address'),
onTap: () async {
final email = hitResult.tryGetLink().mapNotNull((url) => url.host);
if (email != null) {
await Clipboard.setData(ClipboardData(text: email));
}
},
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class CopyImage extends HookConsumerWidget {
final HitResult hitResult;
const CopyImage({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isImage();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.imageMultiple),
title: const Text('Copy image'),
onTap: () async {
final currentTab = ref.read(selectedTabStateProvider);
final url = hitResult.tryGetLink();
if (currentTab != null && url != null) {
await GeckoDownloadsService().copyInternetResource(
currentTab.id,
url: url,
isPrivate: currentTab.isPrivate,
referrerUrl: currentTab.url,
);
}
},
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class CopyImageLocation extends HookConsumerWidget {
final HitResult hitResult;
const CopyImageLocation({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isImage();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.imageMarker),
title: const Text('Copy image location'),
onTap: () async {
await hitResult.tryGetLink().mapNotNull(
(link) => Clipboard.setData(ClipboardData(text: link.toString())),
);
},
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class CopyLink extends HookConsumerWidget {
final HitResult hitResult;
const CopyLink({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isUri() || hitResult.isImage() || hitResult.isVideoAudio();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.contentCopy),
title: const Text('Copy link'),
onTap: () async {
await hitResult.tryGetLink().mapNotNull(
(link) => Clipboard.setData(ClipboardData(text: link.toString())),
);
},
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:lensai/utils/ui_helper.dart';
import 'package:url_launcher/url_launcher.dart';
class LaunchExternal extends HookConsumerWidget {
final HitResult hitResult;
const LaunchExternal({super.key, required this.hitResult});
static Future<bool> isSupported(HitResult hitResult) async {
return hitResult.tryGetLink().mapNotNull((url) => canLaunchUrl(url)) ??
false;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Launch External'),
onTap: () async {
await hitResult.tryGetLink().mapNotNull(
(url) => launchUrlFeedback(context, url),
);
},
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class OpenImageInNewTab extends HookConsumerWidget {
final HitResult hitResult;
const OpenImageInNewTab({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isImage();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Open image in new tab'),
onTap: () async {
final currentTabId = ref.read(selectedTabProvider);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: hitResult.tryGetLink(), parentId: currentTabId);
},
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class OpenInNewTab extends HookConsumerWidget {
final HitResult hitResult;
const OpenInNewTab({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isHttpLink();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Open in new tab'),
onTap: () async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: hitResult.tryGetLink());
},
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class SaveFile extends HookConsumerWidget {
final HitResult hitResult;
const SaveFile({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isVideoAudio() || hitResult.isFile();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.fileDownload),
title: const Text('Save file'),
onTap: () async {
final currentTab = ref.read(selectedTabStateProvider);
final url = hitResult.tryGetLink();
if (currentTab != null && url != null) {
await GeckoDownloadsService().requestDownload(
currentTab.id,
url: url,
skipConfirmation: true,
isPrivate: currentTab.isPrivate,
referrerUrl: currentTab.url,
);
}
},
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class SaveImage extends HookConsumerWidget {
final HitResult hitResult;
const SaveImage({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isImage();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.imageMove),
title: const Text('Save image'),
onTap: () async {
final currentTab = ref.read(selectedTabStateProvider);
final url = hitResult.tryGetLink();
if (currentTab != null && url != null) {
await GeckoDownloadsService().requestDownload(
currentTab.id,
url: url,
skipConfirmation: true,
isPrivate: currentTab.isPrivate,
referrerUrl: currentTab.url,
);
}
},
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:share_plus/share_plus.dart';
class ShareEmail extends HookConsumerWidget {
final HitResult hitResult;
const ShareEmail({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isMailto();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.emailArrowRight),
title: const Text('Share email address'),
onTap: () async {
final email = hitResult.tryGetLink().mapNotNull((url) => url.path);
if (email != null) {
await Share.share(email);
}
},
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
class ShareImage extends HookConsumerWidget {
final HitResult hitResult;
const ShareImage({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isImage();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(MdiIcons.imagePlus),
title: const Text('Share image'),
onTap: () async {
final currentTab = ref.read(selectedTabStateProvider);
final url = hitResult.tryGetLink();
if (currentTab != null && url != null) {
await GeckoDownloadsService().shareInternetResource(
currentTab.id,
url: url,
isPrivate: currentTab.isPrivate,
referrerUrl: currentTab.url,
);
}
},
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:share_plus/share_plus.dart';
class ShareLink extends HookConsumerWidget {
final HitResult hitResult;
const ShareLink({super.key, required this.hitResult});
static bool isSupported(HitResult hitResult) {
return hitResult.isUri() || hitResult.isImage() || hitResult.isVideoAudio();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(Icons.share),
title: const Text('Share link'),
onTap: () async {
await hitResult.tryGetLink().mapNotNull((url) => Share.shareUri(url));
},
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_auto_size_text/flutter_auto_size_text.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/copy_email.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/share_email.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart';
import 'package:lensai/features/geckoview/features/contextmenu/presentation/candidates/share_link.dart';
import 'package:lensai/presentation/hooks/cached_future.dart';
import 'package:skeletonizer/skeletonizer.dart';
class ContextMenuDialog extends HookConsumerWidget {
final HitResult hitResult;
const ContextMenuDialog({super.key, required this.hitResult});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SimpleDialog(
title: AutoSizeText(
hitResult.getTitle(),
minFontSize: 18,
maxFontSize: DefaultTextStyle.of(context).style.fontSize,
maxLines: 10,
overflow: TextOverflow.ellipsis,
softWrap: true,
),
children: [
if (OpenInNewTab.isSupported(hitResult))
OpenInNewTab(hitResult: hitResult),
if (CopyLink.isSupported(hitResult)) CopyLink(hitResult: hitResult),
if (SaveFile.isSupported(hitResult)) SaveFile(hitResult: hitResult),
if (ShareLink.isSupported(hitResult)) ShareLink(hitResult: hitResult),
if (ShareImage.isSupported(hitResult)) ShareImage(hitResult: hitResult),
if (OpenImageInNewTab.isSupported(hitResult))
OpenImageInNewTab(hitResult: hitResult),
if (CopyImage.isSupported(hitResult)) CopyImage(hitResult: hitResult),
if (SaveImage.isSupported(hitResult)) SaveImage(hitResult: hitResult),
if (CopyImageLocation.isSupported(hitResult))
CopyImageLocation(hitResult: hitResult),
if (ShareEmail.isSupported(hitResult)) ShareEmail(hitResult: hitResult),
if (CopyEmail.isSupported(hitResult)) CopyEmail(hitResult: hitResult),
HookBuilder(
builder: (context) {
final isSupported = useCachedFuture(
// ignore: discarded_futures useFuture
() => LaunchExternal.isSupported(hitResult),
);
if (isSupported.data == false) {
return const SizedBox.shrink();
}
return Skeletonizer(
enabled: isSupported.connectionState != ConnectionState.done,
child: LaunchExternal(hitResult: hitResult),
);
},
),
],
);
}
}