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,59 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:kagi_bang_bang/domain/entities/web_page_info.dart';
part 'web_view_page.g.dart';
typedef PageHistory = ({bool canGoBack, bool canGoForward});
@CopyWith(constructor: '_')
class WebViewPage extends WebPageInfo with FastEquatable {
@CopyWithField(immutable: true)
final Key key;
final InAppWebViewController? controller;
// ignore: missing_field_in_equatable_props
final SslError? sslError;
final Uint8List? screenshot;
final PageHistory pageHistory;
WebViewPage({
this.controller,
required super.url,
this.sslError,
super.title,
super.favicon,
this.screenshot,
this.pageHistory = (canGoBack: false, canGoForward: false),
}) : key = GlobalKey();
WebViewPage._({
required this.key,
required this.controller,
required super.url,
required this.sslError,
required super.title,
required super.favicon,
required this.screenshot,
required this.pageHistory,
});
@override
bool get cacheHash => true;
@override
List<Object?> get hashParameters => [
key,
controller,
url,
sslError?.toString(),
title,
favicon?.toString(),
screenshot,
pageHistory,
];
}
@@ -0,0 +1,126 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'web_view_page.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$WebViewPageCWProxy {
WebViewPage controller(InAppWebViewController? controller);
WebViewPage url(Uri url);
WebViewPage sslError(SslError? sslError);
WebViewPage title(String? title);
WebViewPage favicon(Favicon? favicon);
WebViewPage screenshot(Uint8List? screenshot);
WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory);
/// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support.
///
/// Usage
/// ```dart
/// WebViewPage(...).copyWith(id: 12, name: "My name")
/// ````
WebViewPage call({
InAppWebViewController? controller,
Uri? url,
SslError? sslError,
String? title,
Favicon? favicon,
Uint8List? screenshot,
({bool canGoBack, bool canGoForward})? pageHistory,
});
}
/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfWebViewPage.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfWebViewPage.copyWith.fieldName(...)`
class _$WebViewPageCWProxyImpl implements _$WebViewPageCWProxy {
const _$WebViewPageCWProxyImpl(this._value);
final WebViewPage _value;
@override
WebViewPage controller(InAppWebViewController? controller) =>
this(controller: controller);
@override
WebViewPage url(Uri url) => this(url: url);
@override
WebViewPage sslError(SslError? sslError) => this(sslError: sslError);
@override
WebViewPage title(String? title) => this(title: title);
@override
WebViewPage favicon(Favicon? favicon) => this(favicon: favicon);
@override
WebViewPage screenshot(Uint8List? screenshot) => this(screenshot: screenshot);
@override
WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory) =>
this(pageHistory: pageHistory);
@override
/// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support.
///
/// Usage
/// ```dart
/// WebViewPage(...).copyWith(id: 12, name: "My name")
/// ````
WebViewPage call({
Object? controller = const $CopyWithPlaceholder(),
Object? url = const $CopyWithPlaceholder(),
Object? sslError = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? favicon = const $CopyWithPlaceholder(),
Object? screenshot = const $CopyWithPlaceholder(),
Object? pageHistory = const $CopyWithPlaceholder(),
}) {
return WebViewPage._(
key: _value.key,
controller: controller == const $CopyWithPlaceholder()
? _value.controller
// ignore: cast_nullable_to_non_nullable
: controller as InAppWebViewController?,
url: url == const $CopyWithPlaceholder() || url == null
? _value.url
// ignore: cast_nullable_to_non_nullable
: url as Uri,
sslError: sslError == const $CopyWithPlaceholder()
? _value.sslError
// ignore: cast_nullable_to_non_nullable
: sslError as SslError?,
title: title == const $CopyWithPlaceholder()
? _value.title
// ignore: cast_nullable_to_non_nullable
: title as String?,
favicon: favicon == const $CopyWithPlaceholder()
? _value.favicon
// ignore: cast_nullable_to_non_nullable
: favicon as Favicon?,
screenshot: screenshot == const $CopyWithPlaceholder()
? _value.screenshot
// ignore: cast_nullable_to_non_nullable
: screenshot as Uint8List?,
pageHistory:
pageHistory == const $CopyWithPlaceholder() || pageHistory == null
? _value.pageHistory
// ignore: cast_nullable_to_non_nullable
: pageHistory as ({bool canGoBack, bool canGoForward}),
);
}
}
extension $WebViewPageCopyWith on WebViewPage {
/// Returns a callable class that can be used as follows: `instanceOfWebViewPage.copyWith(...)` or like so:`instanceOfWebViewPage.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$WebViewPageCWProxy get copyWith => _$WebViewPageCWProxyImpl(this);
}
@@ -0,0 +1,45 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/web_view.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'web_view.g.dart';
@Riverpod(keepAlive: true)
class WebViewTabController extends _$WebViewTabController {
late Map<Key, WebView> _webViewTabs;
@override
WebView? build() {
_webViewTabs = ref.watch(webViewRepositoryProvider);
return (stateOrNull != null)
? _webViewTabs[stateOrNull!.page.value.key] ??
_webViewTabs.values.lastOrNull
: null;
}
void showTab(Key? key) {
state = (key != null) ? _webViewTabs[key] : null;
}
}
@Riverpod(keepAlive: true)
class WebViewRepository extends _$WebViewRepository {
@override
Map<Key, WebView> build() {
return stateOrNull ?? {};
}
void addTab(WebView webView) {
state = {...state, webView.page.value.key: webView};
}
Future<void> closeTab(Key key) async {
state = Map.of(state)..remove(key);
}
Future<void> closeAllTabs() async {
state = {};
}
}
@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'web_view.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$webViewTabControllerHash() =>
r'45eaa10e6032b04717e8ba08b8dce8ed22764f46';
/// See also [WebViewTabController].
@ProviderFor(WebViewTabController)
final webViewTabControllerProvider =
NotifierProvider<WebViewTabController, WebView?>.internal(
WebViewTabController.new,
name: r'webViewTabControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$webViewTabControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$WebViewTabController = Notifier<WebView?>;
String _$webViewRepositoryHash() => r'96c14f599df30f6828ecaae584a509225b10e2f1';
/// See also [WebViewRepository].
@ProviderFor(WebViewRepository)
final webViewRepositoryProvider =
NotifierProvider<WebViewRepository, Map<Key, WebView>>.internal(
WebViewRepository.new,
name: r'webViewRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$webViewRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$WebViewRepository = Notifier<Map<Key, WebView>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,30 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:kagi_bang_bang/features/web_view/domain/entities/web_view_page.dart';
import 'package:kagi_bang_bang/features/web_view/domain/repositories/web_view.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/web_view.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'switch_new_tab.g.dart';
@riverpod
class SwitchNewTabController extends _$SwitchNewTabController {
@override
FutureOr<void> build() {}
Future<void> add(Uri url) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() async {
final newTab = WebViewPage(
url: WebUri.uri(url),
);
ref
.read(webViewRepositoryProvider.notifier)
.addTab(WebView(tab: newTab));
ref.read(webViewTabControllerProvider.notifier).showTab(newTab.key);
},
);
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'switch_new_tab.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$switchNewTabControllerHash() =>
r'acc20371e95db182cfc5cdc924a289ff56981692';
/// See also [SwitchNewTabController].
@ProviderFor(SwitchNewTabController)
final switchNewTabControllerProvider =
AutoDisposeAsyncNotifierProvider<SwitchNewTabController, void>.internal(
SwitchNewTabController.new,
name: r'switchNewTabControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$switchNewTabControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SwitchNewTabController = AutoDisposeAsyncNotifier<void>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -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!,
),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,39 @@
//https://stackoverflow.com/a/67994693/20878798
import 'package:universal_io/io.dart';
final _utf8FilenameRegex = RegExp(
r"filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)",
caseSensitive: false,
);
final _asciiFilenameRegex = RegExp(
r"""^filename=(["']?)(.*?[^\\])\1(?:; ?|$)""",
caseSensitive: false,
);
String? getDispositionFileName(String contentDisposition) {
if (_utf8FilenameRegex.hasMatch(contentDisposition)) {
final file = Uri.decodeComponent(
_utf8FilenameRegex.firstMatch(contentDisposition)!.group(1)!,
);
return File(file).uri.pathSegments.last;
} else {
// Prevent ReDos attacks by anchoring the ascii regex to string start and
// slicing off everything before 'filename='
final filenameStart = contentDisposition.toLowerCase().indexOf('filename=');
if (filenameStart >= 0) {
final partialDisposition = contentDisposition.substring(filenameStart);
final matches = _asciiFilenameRegex.firstMatch(partialDisposition);
if (matches != null && matches.group(2) != null) {
final file = matches.group(2);
if (file != null) {
return File(file).uri.pathSegments.last;
}
}
}
}
return null;
}
@@ -0,0 +1,23 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
Favicon? choseFavicon(Iterable<Favicon>? favicons, [Favicon? currentIcon]) {
var selectedIcon = currentIcon;
if (favicons != null) {
for (final icon in favicons) {
if (selectedIcon == null) {
selectedIcon = icon;
} else {
if ((selectedIcon.width == null &&
!selectedIcon.url.toString().endsWith("favicon.ico")) ||
(icon.width != null &&
selectedIcon.width != null &&
icon.width! > selectedIcon.width!)) {
selectedIcon = icon;
}
}
}
}
return selectedIcon;
}