first implementation finished

This commit is contained in:
Fabian Freund
2024-04-22 11:21:01 +02:00
parent 1a13ca0795
commit 0e1972242a
102 changed files with 7192 additions and 55 deletions
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:kagi_bang_bang/domain/entities/web_page_info.dart';
import 'package:kagi_bang_bang/extensions/web_uri_favicon.dart';
class FaviconImage extends StatelessWidget {
final double size;
final Icon _iconPlaceholder;
final WebPageInfo webPageInfo;
FaviconImage({
required this.webPageInfo,
this.size = 16,
super.key,
}) : _iconPlaceholder = Icon(
MdiIcons.webBox,
size: size,
);
@override
Widget build(BuildContext context) {
if (webPageInfo.favicon != null) {
return FadeInImage(
placeholder: NetworkImage(
webPageInfo.url.guessFavicon().toString(),
),
image: NetworkImage(webPageInfo.favicon!.url.toString()),
placeholderErrorBuilder: (_, __, ___) => _iconPlaceholder,
imageErrorBuilder: (_, __, ___) => _iconPlaceholder,
height: size,
width: size,
);
} else {
return Image.network(
webPageInfo.url.guessFavicon().toString(),
errorBuilder: (_, __, ___) => _iconPlaceholder,
height: size,
width: size,
);
}
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/domain/entities/web_page_info.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/entities/modes.dart';
import 'package:kagi_bang_bang/features/search_browser/utils/url_builder.dart'
as uri_builder;
import 'package:kagi_bang_bang/features/share_intent/domain/entities/shared_content.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/controllers/switch_new_tab.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/favicon.dart';
import 'package:kagi_bang_bang/presentation/controllers/website_title.dart';
import 'package:kagi_bang_bang/utils/ui_helper.dart' as ui_helper;
import 'package:share_plus/share_plus.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:url_launcher/url_launcher.dart';
class LoadingWebPageDialog extends HookConsumerWidget {
final Uri url;
final void Function()? onDismiss;
const LoadingWebPageDialog(this.url, {this.onDismiss});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pageInfoAsync = ref.watch(pageInfoProvider(url));
return Skeletonizer(
enabled: pageInfoAsync.isLoading,
child: WebPageDialog(
page: pageInfoAsync.valueOrNull ??
WebPageInfo(url: url, favicon: null, title: ''),
onDismiss: onDismiss,
),
);
}
}
class WebPageDialog extends HookConsumerWidget {
final WebPageInfo page;
final InAppWebViewController? webViewController;
final void Function()? onDismiss;
const WebPageDialog({
required this.page,
this.webViewController,
this.onDismiss,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final urlTextController =
useTextEditingController(text: page.url.toString());
return Stack(
children: [
ModalBarrier(
color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54,
onDismiss: onDismiss,
),
SimpleDialog(
titlePadding: const EdgeInsets.fromLTRB(24.0, 12.0, 24.0, 0.0),
contentPadding: EdgeInsets.zero,
insetPadding: const EdgeInsets.symmetric(
horizontal: 20.0,
vertical: 24.0,
),
title: ListTile(
leading: FaviconImage(
webPageInfo: page,
size: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(page.title ?? 'Unknown Title'),
subtitle: Text(page.url.authority),
),
children: [
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: Form(
key: formKey,
child: TextFormField(
controller: urlTextController,
decoration: InputDecoration(
suffixIcon: (webViewController != null)
? IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
await webViewController!.loadUrl(
urlRequest: URLRequest(
url: WebUri.uri(
Uri.parse(
urlTextController.text,
),
),
),
);
onDismiss?.call();
}
},
icon: const Icon(Icons.send),
)
: null,
),
validator: (value) {
if (value != null) {
if (Uri.tryParse(value) case final Uri url) {
if (url.hasScheme && url.hasAuthority) {
return null;
}
}
}
return 'Invalid URL';
},
),
),
),
),
ListTile(
leading: const Icon(MdiIcons.contentCopy),
title: const Text('Copy address'),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: page.url.toString()),
);
onDismiss?.call();
},
),
ListTile(
onTap: () async {
if (!await launchUrl(
page.url,
mode: LaunchMode.externalApplication,
)) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Could not launch URL (${page.url})',
);
}
}
},
leading: const Icon(Icons.open_in_browser),
title: const Text('Launch External'),
),
ListTile(
leading: const Icon(MdiIcons.tabPlus),
title: const Text('Open in new tab'),
onTap: () async {
await ref
.read(switchNewTabControllerProvider.notifier)
.add(page.url);
onDismiss?.call();
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share link'),
onTap: () async {
await Share.shareUri(page.url);
onDismiss?.call();
},
),
const Divider(),
ListTile(
leading: const Icon(MdiIcons.text),
title: const Text('Summarize'),
onTap: () async {
final url = uri_builder.summarizerUri(
document: SharedUrl(page.url),
mode: SummarizerMode.keyMoments,
);
await ref
.read(switchNewTabControllerProvider.notifier)
.add(url);
onDismiss?.call();
},
),
],
),
],
);
}
}
@@ -0,0 +1,359 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/core/logger.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/entities/modes.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/entities/sheet.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/providers.dart';
import 'package:kagi_bang_bang/features/search_browser/utils/url_builder.dart'
as uri_builder;
import 'package:kagi_bang_bang/features/settings/data/repositories/settings_repository.dart';
import 'package:kagi_bang_bang/features/web_view/domain/entities/web_view_page.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/controllers/switch_new_tab.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/web_page_dialog.dart';
import 'package:kagi_bang_bang/features/web_view/utils/favicon_helper.dart';
import 'package:kagi_bang_bang/utils/platform_util.dart' as platform_util;
import 'package:kagi_bang_bang/utils/ui_helper.dart' as ui_helper;
import 'package:url_launcher/url_launcher.dart';
const _webViewSupportedSchemes = [
"http",
"https",
"file",
"chrome",
"data",
"javascript",
"about",
];
class WebView extends StatefulHookConsumerWidget {
final ValueNotifier<WebViewPage> _valueNotifier;
ValueListenable<WebViewPage> get page => _valueNotifier;
void updatePage(WebViewPage Function(WebViewPage page) update) {
_valueNotifier.value = update(_valueNotifier.value);
}
WebView({required WebViewPage tab})
: _valueNotifier = ValueNotifier(tab),
super(key: tab.key);
@override
ConsumerState<ConsumerStatefulWidget> createState() => _WebViewState();
}
class _WebViewState extends ConsumerState<WebView> {
Timer? _onLoadStopDebounce;
Timer? _periodicScreenshotUpdate;
Future<void> _updateScreenshot() async {
final screenshot = await widget.page.value.controller
?.takeScreenshot(
screenshotConfiguration: ScreenshotConfiguration(
compressFormat: CompressFormat.JPEG,
quality: 20,
),
)
.timeout(
const Duration(milliseconds: 1500),
onTimeout: () {
logger.w('Screenshot timed out');
return null;
},
);
widget.updatePage(
(page) => page.copyWith.screenshot(screenshot),
);
}
@override
Future<void> dispose() async {
super.dispose();
_onLoadStopDebounce?.cancel();
_periodicScreenshotUpdate?.cancel();
widget._valueNotifier.dispose();
logger.i('Disposed ${widget.key} (${widget.page.value.title})');
}
@override
Widget build(BuildContext context) {
final initialSettings = useMemoized(
() => InAppWebViewSettings(
// isInspectable: kDebugMode,
useOnDownloadStart: true,
allowsLinkPreview: false,
disableLongPressContextMenuOnLinks: true,
useShouldOverrideUrlLoading: true,
javaScriptEnabled: ref
.read(settingsRepositoryProvider)
.valueOrNull
?.enableJavascript ??
true,
saveFormData: false,
disabledActionModeMenuItems: ActionModeMenuItem.MENU_ITEM_WEB_SEARCH,
),
);
final webViewProgress = useValueNotifier(100);
useOnAppLifecycleStateChange((previous, current) async {
switch (current) {
case AppLifecycleState.paused:
if (platform_util.isAndroid()) {
await widget.page.value.controller?.pause();
}
if (platform_util.isAndroid() || platform_util.isIOS()) {
await widget.page.value.controller?.pauseTimers();
}
case AppLifecycleState.resumed:
if (platform_util.isAndroid()) {
await widget.page.value.controller?.resume();
}
if (platform_util.isAndroid() || platform_util.isIOS()) {
await widget.page.value.controller?.resumeTimers();
}
default:
}
});
ref.listen(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.enableJavascript),
(previous, next) async {
await widget.page.value.controller?.setSettings(
settings: initialSettings.copy()..javaScriptEnabled = next,
);
},
);
return Stack(
children: [
InAppWebView(
initialUrlRequest: URLRequest(url: WebUri.uri(widget.page.value.url)),
initialSettings: initialSettings,
contextMenu: ContextMenu(
menuItems: [
ContextMenuItem(
id: 1,
title: "Search",
action: () async {
final selectedText =
await widget.page.value.controller?.getSelectedText();
if (selectedText != null && selectedText.isNotEmpty) {
final url = uri_builder.searchUri(
searchQuery: selectedText,
);
await ref
.read(switchNewTabControllerProvider.notifier)
.add(url);
}
},
),
ContextMenuItem(
id: 2,
title: "Assistant",
action: () async {
final selectedText =
await widget.page.value.controller?.getSelectedText();
if (selectedText != null && selectedText.isNotEmpty) {
ref.read(bottomSheetProvider.notifier).show(
CreateTab(
content: selectedText,
preferredTool: KagiTool.assistant,
),
);
}
},
),
],
),
onWebViewCreated: (controller) async {
if (platform_util.isAndroid()) {
await controller.startSafeBrowsing();
}
widget.updatePage((page) => page.copyWith.controller(controller));
},
onReceivedServerTrustAuthRequest: (controller, challenge) async {
final sslError = challenge.protectionSpace.sslError;
if (sslError != null && sslError.code != null) {
if (challenge.protectionSpace.host ==
await controller.getUrl().then((value) => value?.host)) {
widget.updatePage((page) => page.copyWith.sslError(sslError));
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'We detected an security issue and did not continue to ${widget.page.value.url.authority}: ${sslError.message}',
);
}
}
return ServerTrustAuthResponse(
// ignore: avoid_redundant_argument_values
action: ServerTrustAuthResponseAction.CANCEL,
);
}
widget.updatePage((page) => page.copyWith.sslError(null));
return ServerTrustAuthResponse(
action: ServerTrustAuthResponseAction.PROCEED,
);
},
onProgressChanged: (controller, progress) {
webViewProgress.value = progress;
},
onLoadStart: (controller, url) {
if (url != null) {
widget.updatePage(
(page) => page.copyWith(
url: url,
// ignore: avoid_redundant_argument_values
sslError: null,
),
);
}
},
onLoadStop: (controller, url) {
if (url != null) {
widget.updatePage((page) => page.copyWith.url(url));
}
_onLoadStopDebounce?.cancel();
_onLoadStopDebounce =
Timer(const Duration(milliseconds: 150), () async {
final favicon = await widget.page.value.controller
?.getFavicons()
.then((icons) => choseFavicon(icons));
widget.updatePage((page) => page.copyWith.favicon(favicon));
await _updateScreenshot().whenComplete(() {
_periodicScreenshotUpdate?.cancel();
_periodicScreenshotUpdate =
Timer.periodic(const Duration(seconds: 5), (timer) async {
await _updateScreenshot().onError((error, stackTrace) {
logger.e(error, stackTrace: stackTrace);
timer.cancel();
});
});
});
});
},
onUpdateVisitedHistory: (controller, url, isReload) async {
if (isReload != true) {
final history = (
canGoBack: await controller.canGoBack(),
canGoForward: await controller.canGoForward()
);
widget.updatePage((page) => page.copyWith.pageHistory(history));
}
},
shouldOverrideUrlLoading: (controller, navigationAction) async {
final url = navigationAction.request.url;
if (url != null) {
final launchExternal = ref
.read(settingsRepositoryProvider)
.valueOrNull
?.launchUrlExternal ??
false;
final unhandledScheme =
!_webViewSupportedSchemes.contains(url.scheme);
if (unhandledScheme ||
(launchExternal && url.host != 'kagi.com')) {
if (await canLaunchUrl(url)) {
var success = false;
if (unhandledScheme) {
success = await launchUrl(url);
} else if (launchExternal) {
success = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
}
if (!success) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Could not launch URL ($url)',
);
}
}
// and cancel the request
return NavigationActionPolicy.CANCEL;
}
}
}
return NavigationActionPolicy.ALLOW;
},
onLongPressHitTestResult: (controller, hitTestResult) async {
if (switch (hitTestResult.type) {
InAppWebViewHitTestResultType.SRC_IMAGE_ANCHOR_TYPE ||
InAppWebViewHitTestResultType.SRC_ANCHOR_TYPE ||
InAppWebViewHitTestResultType.IMAGE_TYPE =>
true,
_ => false,
}) {
final requestFocusNodeHrefResult =
await controller.requestFocusNodeHref();
final url = requestFocusNodeHrefResult?.url ??
Uri.tryParse(requestFocusNodeHrefResult?.src ?? '');
if (url?.hasScheme == true && url?.hasAuthority == true) {
ref.read(overlayDialogProvider.notifier).show(
LoadingWebPageDialog(
url!,
onDismiss:
ref.watch(overlayDialogProvider.notifier).dismiss,
),
);
}
}
},
onTitleChanged: (controller, title) {
widget.updatePage((page) => page.copyWith.title(title));
},
// onDownloadStartRequest: (controller, downloadStartRequest) {
// final regex = RegExp(
// r"filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)",
// caseSensitive: false,
// );
// final math =
// regex.firstMatch(downloadStartRequest.contentDisposition!);
// print(math);
// },
),
HookBuilder(
builder: (context) {
final value = useValueListenable(webViewProgress);
return Visibility(
visible: value < 100,
child: LinearProgressIndicator(
value: value / 100,
),
);
},
),
],
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/web_view/domain/repositories/web_view.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/favicon.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/web_view.dart';
class WebViewTab extends HookConsumerWidget {
final WebView webView;
final bool isActive;
final VoidCallback onClose;
const WebViewTab({
required this.webView,
required this.isActive,
required this.onClose,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final page = useValueListenable(webView.page);
return Container(
key: UniqueKey(),
decoration: BoxDecoration(
border: Border.all(
color: isActive ? colorScheme.primary : colorScheme.outline,
width: isActive ? 2.0 : 1.0,
),
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
),
child: Material(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
onTap: () {
if (!isActive) {
ref.read(webViewTabControllerProvider.notifier).showTab(page.key);
} else {
onClose();
}
},
child: Column(
children: [
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Text(
overflow: TextOverflow.ellipsis,
page.title ?? 'New Tab',
maxLines: 2,
),
),
),
IconButton(
visualDensity:
const VisualDensity(horizontal: -4.0, vertical: -4.0),
onPressed: () async {
await ref
.read(webViewRepositoryProvider.notifier)
.closeTab(page.key);
},
icon: const Icon(Icons.close),
),
],
),
Row(
children: [
const SizedBox(
width: 6.0,
),
FaviconImage(
webPageInfo: page,
),
const SizedBox(
width: 6.0,
),
Expanded(
child: Text(
page.url.authority,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
const SizedBox(
height: 6,
),
if (page.screenshot != null)
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16.0),
bottomRight: Radius.circular(16.0),
),
child: SizedBox(
width: double.infinity,
child: Image.memory(
fit: BoxFit.fitWidth,
page.screenshot!,
),
),
),
),
],
),
),
),
);
}
}