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
+2 -2
View File
@@ -21,8 +21,8 @@ linter:
- collection_methods_unrelated_type
analyzer:
# plugins:
# - custom_lint
plugins:
- custom_lint
exclude:
- "**.g.dart"
- "**.swagger.dart"
+14 -8
View File
@@ -25,26 +25,32 @@ if (flutterVersionName == null) {
android {
namespace = "me.movenext.kagi_bang_bang"
compileSdk = flutter.compileSdkVersion
compileSdk = 34
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "me.movenext.kagi_bang_bang"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// https://api.flutter.dev/flutter/widgets/AndroidView-class.html
//https://inappwebview.dev/docs/intro/#setup-android
minSdk = 23
targetSdk = 34
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
}
dependencies {
// implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// implementation 'androidx.glance:glance-appwidget:1.0.0'
implementation 'com.google.android.material:material:1.11.0'
}
@@ -59,4 +65,4 @@ android {
flutter {
source = "../.."
}
}
+20 -8
View File
@@ -25,18 +25,29 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="me.movenext.kagi_bang_bang.action.LAUNCH" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- This data tag matches all HTTP and HTTPS URLs -->
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*" />
</intent-filter>
</activity>
<receiver android:name="KagiWidgetProvider" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider"
android:resource="@xml/kagi_widget_info" />
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -48,10 +59,11 @@
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- This will show a lot of app actions in context menu when selecting text
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</queries> -->
</manifest>
@@ -0,0 +1,40 @@
package me.movenext.kagi_bang_bang
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.net.Uri
import android.view.View
import android.widget.RemoteViews
import es.antonborri.home_widget.HomeWidgetBackgroundIntent
import es.antonborri.home_widget.HomeWidgetLaunchIntent
import es.antonborri.home_widget.HomeWidgetProvider
class KagiWidgetProvider : HomeWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, widgetData: SharedPreferences) {
appWidgetIds.forEach { widgetId ->
val views = RemoteViews(context.packageName, R.layout.kagi_widget).apply {
val searchIntent = HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("widget://search"))
setOnClickPendingIntent(R.id.search_button, searchIntent)
val assistantIntent = HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("widget://assistant"))
setOnClickPendingIntent(R.id.assistant_button, assistantIntent)
val summarizerIntent = HomeWidgetLaunchIntent.getActivity(
context,
MainActivity::class.java,
Uri.parse("widget://summarizer"))
setOnClickPendingIntent(R.id.summarizer_button, summarizerIntent)
}
appWidgetManager.updateAppWidget(widgetId, views)
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF"/> <!-- Background color -->
<corners android:radius="18dp"/> <!-- Corner radius -->
</shape>
@@ -0,0 +1 @@
<!-- drawable/brain.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M21.33,12.91C21.42,14.46 20.71,15.95 19.44,16.86L20.21,18.35C20.44,18.8 20.47,19.33 20.27,19.8C20.08,20.27 19.69,20.64 19.21,20.8L18.42,21.05C18.25,21.11 18.06,21.14 17.88,21.14C17.37,21.14 16.89,20.91 16.56,20.5L14.44,18C13.55,17.85 12.71,17.47 12,16.9C11.5,17.05 11,17.13 10.5,17.13C9.62,17.13 8.74,16.86 8,16.34C7.47,16.5 6.93,16.57 6.38,16.56C5.59,16.57 4.81,16.41 4.08,16.11C2.65,15.47 1.7,14.07 1.65,12.5C1.57,11.78 1.69,11.05 2,10.39C1.71,9.64 1.68,8.82 1.93,8.06C2.3,7.11 3,6.32 3.87,5.82C4.45,4.13 6.08,3 7.87,3.12C9.47,1.62 11.92,1.46 13.7,2.75C14.12,2.64 14.56,2.58 15,2.58C16.36,2.55 17.65,3.15 18.5,4.22C20.54,4.75 22,6.57 22.08,8.69C22.13,9.8 21.83,10.89 21.22,11.82C21.29,12.18 21.33,12.54 21.33,12.91M16.33,11.5C16.9,11.57 17.35,12 17.35,12.57A1,1 0 0,1 16.35,13.57H15.72C15.4,14.47 14.84,15.26 14.1,15.86C14.35,15.95 14.61,16 14.87,16.07C20,16 19.4,12.87 19.4,12.82C19.34,11.39 18.14,10.27 16.71,10.33A1,1 0 0,1 15.71,9.33A1,1 0 0,1 16.71,8.33C17.94,8.36 19.12,8.82 20.04,9.63C20.09,9.34 20.12,9.04 20.12,8.74C20.06,7.5 19.5,6.42 17.25,6.21C16,3.25 12.85,4.89 12.85,5.81V5.81C12.82,6.04 13.06,6.53 13.1,6.56A1,1 0 0,1 14.1,7.56C14.1,8.11 13.65,8.56 13.1,8.56V8.56C12.57,8.54 12.07,8.34 11.67,8C11.19,8.31 10.64,8.5 10.07,8.56V8.56C9.5,8.61 9.03,8.21 9,7.66C8.92,7.1 9.33,6.61 9.88,6.56C10.04,6.54 10.82,6.42 10.82,5.79V5.79C10.82,5.13 11.07,4.5 11.5,4C10.58,3.75 9.59,4.08 8.59,5.29C6.75,5 6,5.25 5.45,7.2C4.5,7.67 4,8 3.78,9C4.86,8.78 5.97,8.87 7,9.25C7.5,9.44 7.78,10 7.59,10.54C7.4,11.06 6.82,11.32 6.3,11.13C5.57,10.81 4.75,10.79 4,11.07C3.68,11.34 3.68,11.9 3.68,12.34C3.68,13.08 4.05,13.77 4.68,14.17C5.21,14.44 5.8,14.58 6.39,14.57C6.24,14.31 6.11,14.04 6,13.76C5.81,13.22 6.1,12.63 6.64,12.44C7.18,12.25 7.77,12.54 7.96,13.08C8.36,14.22 9.38,15 10.58,15.13C11.95,15.06 13.17,14.25 13.77,13C14,11.62 15.11,11.5 16.33,11.5M18.33,18.97L17.71,17.67L17,17.83L18,19.08L18.33,18.97M13.68,10.36C13.7,9.83 13.3,9.38 12.77,9.33C12.06,9.29 11.37,9.53 10.84,10C10.27,10.58 9.97,11.38 10,12.19A1,1 0 0,0 11,13.19C11.57,13.19 12,12.74 12,12.19C12,11.92 12.07,11.65 12.23,11.43C12.35,11.33 12.5,11.28 12.66,11.28C13.21,11.31 13.68,10.9 13.68,10.36Z" /></vector>
@@ -0,0 +1 @@
<!-- drawable/search_web.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M15.5,14L20.5,19L19,20.5L14,15.5V14.71L13.73,14.43C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.43,13.73L14.71,14H15.5M9.5,4.5L8.95,4.53C8.71,5.05 8.34,5.93 8.07,7H10.93C10.66,5.93 10.29,5.05 10.05,4.53C9.87,4.5 9.69,4.5 9.5,4.5M13.83,7C13.24,5.97 12.29,5.17 11.15,4.78C11.39,5.31 11.7,6.08 11.93,7H13.83M5.17,7H7.07C7.3,6.08 7.61,5.31 7.85,4.78C6.71,5.17 5.76,5.97 5.17,7M4.5,9.5C4.5,10 4.58,10.53 4.73,11H6.87L6.75,9.5L6.87,8H4.73C4.58,8.47 4.5,9 4.5,9.5M14.27,11C14.42,10.53 14.5,10 14.5,9.5C14.5,9 14.42,8.47 14.27,8H12.13C12.21,8.5 12.25,9 12.25,9.5C12.25,10 12.21,10.5 12.13,11H14.27M7.87,8L7.75,9.5L7.87,11H11.13C11.21,10.5 11.25,10 11.25,9.5C11.25,9 11.21,8.5 11.13,8H7.87M9.5,14.5C9.68,14.5 9.86,14.5 10.03,14.47C10.28,13.95 10.66,13.07 10.93,12H8.07C8.34,13.07 8.72,13.95 8.97,14.47L9.5,14.5M13.83,12H11.93C11.7,12.92 11.39,13.69 11.15,14.22C12.29,13.83 13.24,13.03 13.83,12M5.17,12C5.76,13.03 6.71,13.83 7.85,14.22C7.61,13.69 7.3,12.92 7.07,12H5.17Z" /></vector>
@@ -0,0 +1 @@
<!-- drawable/text.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M21,6V8H3V6H21M3,18H12V16H3V18M3,13H21V11H3V13Z" /></vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#333237"/> <!-- Background color -->
<corners android:radius="32dp"/> <!-- Rounded corners -->
<stroke android:width="1dp" android:color="#BDBDBD"/> <!-- Border -->
</shape>
@@ -0,0 +1,55 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.KagiWidget.AppWidget.Container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:theme="@style/Theme.KagiWidget.AppWidgetContainer">
<Button
android:id="@+id/search_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/search_text_field"
android:text="Search on Kagi..."
android:textColor="#848388"
android:drawablePadding="8dp"
android:padding="12dp"
android:drawableStart="@drawable/mdi_icon_search_web"
android:drawableTint="#848388"
android:gravity="start|center_vertical" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingTop="12dp">
<Button
android:layout_marginEnd="8dp"
android:id="@+id/assistant_button"
android:padding="16dp"
android:backgroundTint="#333237"
android:background="@drawable/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTint="@color/white"
android:drawableStart="@drawable/mdi_icon_brain"
android:drawablePadding="8dp"
android:text="Assistant" />
<Button
android:layout_marginStart="8dp"
android:id="@+id/summarizer_button"
android:padding="16dp"
android:backgroundTint="#333237"
android:background="@drawable/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTint="@color/white"
android:drawableStart="@drawable/mdi_icon_text"
android:drawablePadding="8dp"
android:text="Summarizer" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,7 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetPadding" format="dimension" />
<attr name="appWidgetInnerRadius" format="dimension" />
<attr name="appWidgetRadius" format="dimension" />
</declare-styleable>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Refer to App Widget Documentation for margin information
http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
</resources>
+11 -1
View File
@@ -12,7 +12,17 @@
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="Widget.KagiWidget.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:background">?android:attr/colorBackground</item>
</style>
<style name="Widget.KagiWidget.AppWidget.InnerView" parent="android:Widget">
<item name="android:background">?android:attr/colorBackground</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
</resources>
@@ -0,0 +1,31 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.KagiWidget" parent="@style/Theme.Material3.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
<style name="Theme.KagiWidget.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<!-- Radius of the outer bound of widgets to make the rounded corners -->
<item name="appWidgetRadius">16dp</item>
<!--
Radius of the inner view's bound of widgets to make the rounded corners.
It needs to be 8dp or less than the value of appWidgetRadius
-->
<item name="appWidgetInnerRadius">8dp</item>
</style>
<style name="Theme.KagiWidget.AppWidgetContainer" parent="Theme.KagiWidget.AppWidgetContainerParent">
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
<item name="appWidgetPadding">16dp</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/kagi_widget"
android:resizeMode="none"
android:previewLayout="@layout/kagi_widget"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:widgetCategory="home_screen" />
+4
View File
@@ -1,3 +1,7 @@
buildscript {
ext.kotlin_version = '1.9.22'
}
allprojects {
repositories {
google()
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "org.jetbrains.kotlin.android" version "1.9.22" apply false
}
include ":app"
@@ -0,0 +1,15 @@
import 'package:exceptions/exceptions.dart';
import 'package:riverpod/riverpod.dart';
extension AsyncExtension<T> on Result<T> {
AsyncValue<T> toAsyncValue() {
return switch (this) {
Success(value: final value) => AsyncValue.data(value),
// TODO: Handle this case.
Failure(
error: final error,
) =>
AsyncError(error.message, error.stackTrace ?? StackTrace.empty),
};
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:exceptions/exceptions.dart';
import 'package:http/http.dart';
import 'package:universal_io/io.dart';
ErrorMessage handleHttpError(
Exception exception,
StackTrace stackTrace,
) {
return switch (exception) {
SocketException() => const ErrorMessage(
source: 'http',
message: 'Could not contact remote service',
),
HttpException() => const ErrorMessage(
source: 'http',
message: 'Web request returned error',
),
FormatException() => const ErrorMessage(
source: 'http',
message: 'Bad response format',
),
ClientException() => const ErrorMessage(
source: 'http',
message: 'Could not contact remote service',
),
_ => ErrorMessage.fromException(
exception,
stackTrace,
)
};
}
+3
View File
@@ -0,0 +1,3 @@
import 'package:logger/logger.dart';
final logger = Logger();
+11
View File
@@ -0,0 +1,11 @@
import 'package:go_router/go_router.dart';
import 'package:kagi_bang_bang/core/routing/routes.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
GoRouter router(RouterRef ref) => GoRouter(
debugLogDiagnostics: true,
routes: $appRoutes,
);
+24
View File
@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$routerHash() => r'4ad45f430b1f43e427e21122d16bd761ca04ae0e';
/// See also [router].
@ProviderFor(router)
final routerProvider = Provider<GoRouter>.internal(
router,
name: r'routerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$routerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef RouterRef = ProviderRef<GoRouter>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/screens/browser.dart';
import 'package:kagi_bang_bang/features/settings/data/repositories/settings_repository.dart';
import 'package:kagi_bang_bang/features/settings/presentation/screens/settings.dart';
part 'routes.g.dart';
@TypedGoRoute<KagiRoute>(
path: '/',
)
class KagiRoute extends GoRouteData {
KagiRoute();
@override
Future<String?> redirect(BuildContext context, GoRouterState state) async {
final hasKagiSession = await ProviderScope.containerOf(context)
.read(settingsRepositoryProvider.future)
.then((value) => value.kagiSession?.isNotEmpty ?? false);
if (!hasKagiSession) {
return SettingsRoute().location;
}
return null;
}
@override
Widget build(BuildContext context, GoRouterState state) {
return const KagiScreen();
}
}
@TypedGoRoute<SettingsRoute>(
path: '/settings',
)
class SettingsRoute extends GoRouteData {
@override
Widget build(BuildContext context, GoRouterState state) {
return const SettingsScreen();
}
}
+56
View File
@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'routes.dart';
// **************************************************************************
// GoRouterGenerator
// **************************************************************************
List<RouteBase> get $appRoutes => [
$kagiRoute,
$settingsRoute,
];
RouteBase get $kagiRoute => GoRouteData.$route(
path: '/',
factory: $KagiRouteExtension._fromState,
);
extension $KagiRouteExtension on KagiRoute {
static KagiRoute _fromState(GoRouterState state) => KagiRoute();
String get location => GoRouteData.$location(
'/',
);
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);
}
RouteBase get $settingsRoute => GoRouteData.$route(
path: '/settings',
factory: $SettingsRouteExtension._fromState,
);
extension $SettingsRouteExtension on SettingsRoute {
static SettingsRoute _fromState(GoRouterState state) => SettingsRoute();
String get location => GoRouteData.$location(
'/settings',
);
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);
}
@@ -0,0 +1,6 @@
class ReceivedParameter {
final String? content;
final String? tool;
ReceivedParameter(this.content, this.tool);
}
@@ -0,0 +1,32 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
class WebPageInfo {
final Uri url;
final String? title;
final Favicon? favicon;
WebPageInfo({
required this.url,
required this.title,
required this.favicon,
});
factory WebPageInfo.fromJson(Map<String, dynamic> json) {
return WebPageInfo(
url: Uri.parse(json['url'] as String),
title: json['title'] as String?,
favicon: switch (json['favicon']) {
final Map<String, dynamic> favicon => Favicon.fromMap(favicon),
_ => null
},
);
}
Map<String, dynamic> toJson() {
return {
'url': url.toString(),
'title': title,
'favicon': favicon?.toJson(),
};
}
}
@@ -0,0 +1,73 @@
import 'package:exceptions/exceptions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:http/http.dart' as http;
import 'package:kagi_bang_bang/core/http_error_handler.dart';
import 'package:kagi_bang_bang/domain/entities/web_page_info.dart';
import 'package:kagi_bang_bang/features/web_view/utils/favicon_helper.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'generic_website.g.dart';
@Riverpod(keepAlive: true)
class GenericWebsiteService extends _$GenericWebsiteService {
late http.Client _client;
@override
void build() {
_client = http.Client();
}
static Iterable<Favicon> _extractFavicons(Uri url, Document document) sync* {
final links = document.querySelectorAll(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
);
for (final link in links) {
final href = link.attributes['href'];
if (href != null) {
// Attempt to parse height and width if available
int? height;
int? width;
if (link.attributes['sizes'] case final String sizes) {
final dimensions = sizes.split('x');
if (dimensions.length == 2) {
height = int.tryParse(dimensions[0]);
width = int.tryParse(dimensions[1]);
}
}
yield Favicon(
url: WebUri.uri(url.resolve(href)),
rel: link.attributes['rel'],
width: width,
height: height,
);
}
}
}
Future<Result<WebPageInfo>> getInfo(Uri url) async {
return Result.fromAsync(
() async {
final response = await _client.get(url);
return await compute(
(args) {
final document = html_parser.parse(args[0]);
final url = Uri.parse(args[1]);
final title = document.querySelector('title')?.text;
final favicon = choseFavicon(_extractFavicons(url, document));
return WebPageInfo(url: url, title: title, favicon: favicon)
.toJson();
},
[response.body, url.toString()],
).then(WebPageInfo.fromJson);
},
exceptionHandler: handleHttpError,
);
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'generic_website.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$genericWebsiteServiceHash() =>
r'd3d5ec8600842eb2b7fa40f248e5e448c96e987f';
/// See also [GenericWebsiteService].
@ProviderFor(GenericWebsiteService)
final genericWebsiteServiceProvider =
NotifierProvider<GenericWebsiteService, void>.internal(
GenericWebsiteService.new,
name: r'genericWebsiteServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$genericWebsiteServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$GenericWebsiteService = Notifier<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
+6
View File
@@ -0,0 +1,6 @@
extension WebUriFavicon on Uri {
Uri guessFavicon() => removeFragment().replace(
path: 'favicon.ico',
queryParameters: {},
);
}
@@ -0,0 +1,23 @@
import 'package:home_widget/home_widget.dart';
import 'package:kagi_bang_bang/domain/entities/received_parameter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
part 'home_widget.g.dart';
@Riverpod(keepAlive: true)
FutureOr<bool> widgetPinnable(WidgetPinnableRef ref) async {
return await HomeWidget.isRequestPinWidgetSupported() ?? false;
}
@riverpod
Raw<Stream<ReceivedParameter>> appWidgetLaunchStream(
AppWidgetLaunchStreamRef ref,
) {
// ignore: discarded_futures
final initialStream = HomeWidget.initiallyLaunchedFromHomeWidget().asStream();
return ConcatStream([initialStream, HomeWidget.widgetClicked])
.whereNotNull()
.map((uri) => ReceivedParameter(null, uri.host));
}
@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'home_widget.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$widgetPinnableHash() => r'c39a8b473a92ff81fa772d7c235b10736e4a49bd';
/// See also [widgetPinnable].
@ProviderFor(widgetPinnable)
final widgetPinnableProvider = FutureProvider<bool>.internal(
widgetPinnable,
name: r'widgetPinnableProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$widgetPinnableHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef WidgetPinnableRef = FutureProviderRef<bool>;
String _$appWidgetLaunchStreamHash() =>
r'1cda869c62e270be74efcb9b052bc498cfbd98a5';
/// See also [appWidgetLaunchStream].
@ProviderFor(appWidgetLaunchStream)
final appWidgetLaunchStreamProvider =
AutoDisposeProvider<Raw<Stream<ReceivedParameter>>>.internal(
appWidgetLaunchStream,
name: r'appWidgetLaunchStreamProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$appWidgetLaunchStreamHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef AppWidgetLaunchStreamRef
= AutoDisposeProviderRef<Raw<Stream<ReceivedParameter>>>;
// 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,37 @@
import 'dart:convert';
import 'package:exceptions/exceptions.dart';
import 'package:http/http.dart' as http;
import 'package:kagi_bang_bang/core/http_error_handler.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'autosuggest.g.dart';
@Riverpod(keepAlive: true)
class KagiAutosuggestService extends _$KagiAutosuggestService {
static final _baseUrl = Uri.https('kagi.com', 'api/autosuggest');
late http.Client _client;
@override
void build() {
_client = http.Client();
}
Future<Result<List<String>>> getSuggestions(String query) async {
return Result.fromAsync(
() async {
final response =
await _client.get(_baseUrl.replace(queryParameters: {'q': query}));
final results = jsonDecode(response.body) as List;
return switch (results.last) {
final String result => [result],
final List resultList => resultList.cast(),
_ => []
};
},
exceptionHandler: handleHttpError,
);
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'autosuggest.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$kagiAutosuggestServiceHash() =>
r'97fa2170d6756f55a2c6d8454cfc3d8c33ded914';
/// See also [KagiAutosuggestService].
@ProviderFor(KagiAutosuggestService)
final kagiAutosuggestServiceProvider =
NotifierProvider<KagiAutosuggestService, void>.internal(
KagiAutosuggestService.new,
name: r'kagiAutosuggestServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$kagiAutosuggestServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$KagiAutosuggestService = Notifier<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,57 @@
import 'dart:async';
import 'package:kagi_bang_bang/features/kagi/data/services/autosuggest.dart';
import 'package:kagi_bang_bang/utils/lru_cache.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
part 'autosuggest.g.dart';
@Riverpod(keepAlive: true)
class AutosuggestRepository extends _$AutosuggestRepository {
final LRUCache<String, List<String>> _cache;
late StreamController<String> _queryStreamController;
AutosuggestRepository() : _cache = LRUCache(100);
void addQuery(String query) {
_queryStreamController.add(query);
}
@override
Raw<Stream<List<String>>> build() {
_queryStreamController = StreamController();
ref.onDispose(() async {
await _queryStreamController.close();
});
return _queryStreamController.stream
.sampleTime(const Duration(milliseconds: 100))
.switchMap<List<String>>(
(query) {
if (query.isEmpty) {
return Stream.value([]);
}
final cached = _cache.get(query);
if (cached != null) {
return Stream.value(cached);
}
return ref
.read(kagiAutosuggestServiceProvider.notifier)
// ignore: discarded_futures
.getSuggestions(query)
// ignore: discarded_futures
.then((result) {
result.onSuccess((result) {
_cache.set(query, result);
});
return result.value;
}).asStream();
},
).asBroadcastStream();
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'autosuggest.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$autosuggestRepositoryHash() =>
r'48d6bf45d648871f8570edfe1e0b394019084695';
/// See also [AutosuggestRepository].
@ProviderFor(AutosuggestRepository)
final autosuggestRepositoryProvider =
NotifierProvider<AutosuggestRepository, Raw<Stream<List<String>>>>.internal(
AutosuggestRepository.new,
name: r'autosuggestRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$autosuggestRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AutosuggestRepository = Notifier<Raw<Stream<List<String>>>>;
// 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,46 @@
enum KagiTool { search, assistant, summarizer }
enum AssistantMode {
research(7),
code(5),
chat(4),
custom(6);
final int value;
const AssistantMode(this.value);
}
enum ResearchVariant {
fast(1),
expert(2);
final int value;
const ResearchVariant(this.value);
}
enum ChatModel {
gpt35Turbo('GPT 3.5 Turbo', 1),
gpt4('GPT 4', 2),
gpt4Turbo('GPT 4 Turbo', 7),
claude3Haiku('Claude 3 Haiku', 13),
claude3Sonnet('Claude 3 Sonnet', 14),
claude3Opus('Claude 3 Opus', 12),
mistralSmall('Mistral Small', 10),
mistralLarge('Mistral Large', 11);
final int value;
final String label;
const ChatModel(this.label, this.value);
}
enum SummarizerMode {
keyMoments('takeaway'),
summary('summary');
final String value;
const SummarizerMode(this.value);
}
@@ -0,0 +1,14 @@
import 'package:kagi_bang_bang/features/search_browser/domain/entities/modes.dart';
sealed class Sheet {}
class CreateTab extends Sheet {
final String? content;
final KagiTool? preferredTool;
bool get hasParameters => content != null || preferredTool != null;
CreateTab({this.content, this.preferredTool});
}
class ViewTabs extends Sheet {}
@@ -0,0 +1,44 @@
// ignore_for_file: use_setters_to_change_properties
import 'package:flutter/widgets.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/entities/sheet.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/services/create_tab.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@Riverpod()
class OverlayDialog extends _$OverlayDialog {
@override
Widget? build() {
return null;
}
void show(Widget dialog) {
state = dialog;
}
void dismiss() {
state = null;
}
}
@Riverpod()
class BottomSheet extends _$BottomSheet {
@override
Sheet? build() {
return ref.watch(
createTabStreamProvider.select(
(value) => value.valueOrNull,
),
);
}
void show(Sheet sheet) {
state = sheet;
}
void dismiss() {
state = null;
}
}
@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$overlayDialogHash() => r'7a0376fff0ba12c70a257a47391e7c049dabd36d';
/// See also [OverlayDialog].
@ProviderFor(OverlayDialog)
final overlayDialogProvider =
AutoDisposeNotifierProvider<OverlayDialog, Widget?>.internal(
OverlayDialog.new,
name: r'overlayDialogProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$overlayDialogHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$OverlayDialog = AutoDisposeNotifier<Widget?>;
String _$bottomSheetHash() => r'31108d6d80d5b858024a8a8ae9ebb9c650eabe70';
/// See also [BottomSheet].
@ProviderFor(BottomSheet)
final bottomSheetProvider =
AutoDisposeNotifierProvider<BottomSheet, Sheet?>.internal(
BottomSheet.new,
name: r'bottomSheetProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$bottomSheetHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$BottomSheet = AutoDisposeNotifier<Sheet?>;
// 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,53 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:kagi_bang_bang/domain/entities/received_parameter.dart';
import 'package:kagi_bang_bang/features/app_widget/domain/services/home_widget.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/share_intent/domain/services/sharing_intent.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
part 'create_tab.g.dart';
final _parameterToCreateTabTransformer =
StreamTransformer<ReceivedParameter, CreateTab>.fromHandlers(
handleData: (parameter, sink) {
final createTab = CreateTab(
preferredTool: KagiTool.values
.firstWhereOrNull((tool) => tool.name == parameter.tool),
content: parameter.content,
);
if (createTab.hasParameters) {
sink.add(createTab);
}
},
);
@Riverpod()
class CreateTabStream extends _$CreateTabStream {
late StreamController<CreateTab> _streamController;
@override
Stream<CreateTab> build() {
_streamController = StreamController();
ref.onDispose(() async {
await _streamController.close();
});
final sharingItentStream = ref.watch(sharingIntentStreamProvider);
final appWidgetLaunchStream = ref.watch(appWidgetLaunchStreamProvider);
return MergeStream([
sharingItentStream.transform(_parameterToCreateTabTransformer),
appWidgetLaunchStream.transform(_parameterToCreateTabTransformer),
_streamController.stream,
]);
}
void createTab(CreateTab parameter) {
_streamController.add(parameter);
}
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_tab.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$createTabStreamHash() => r'3e85020edbebf3ec9927dba6d05af9f06a360a14';
/// See also [CreateTabStream].
@ProviderFor(CreateTabStream)
final createTabStreamProvider =
AutoDisposeStreamNotifierProvider<CreateTabStream, CreateTab>.internal(
CreateTabStream.new,
name: r'createTabStreamProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$createTabStreamHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CreateTabStream = AutoDisposeStreamNotifier<CreateTab>;
// 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,53 @@
import 'dart:async';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'session.g.dart';
@Riverpod()
class SessionService extends _$SessionService {
final _cookieManager = CookieManager.instance();
Future<void> setKagiSession(String session) async {
await _cookieManager.setCookie(
url: WebUri.uri(Uri.https('kagi.com')),
name: 'kagi_session',
value: session,
domain: 'kagi.com',
isHttpOnly: true,
isSecure: true,
sameSite: HTTPCookieSameSitePolicy.LAX,
);
}
Future<void> clearAllData() async {
await _cookieManager.deleteAllCookies();
final webViewLoaded = Completer<void>();
final headlessWebView = HeadlessInAppWebView(
initialSettings: InAppWebViewSettings(
//Clears all data on (just) Android
incognito: true,
//Clear other stuff
clearCache: true,
clearSessionCache: true,
),
onWebViewCreated: (controller) {
//wait until settings are applied for sure
webViewLoaded.complete();
},
);
unawaited(headlessWebView.run());
await webViewLoaded.future.whenComplete(() => headlessWebView..dispose());
}
void initializationDone() {
state = true;
}
@override
bool build() => false;
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'session.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sessionServiceHash() => r'48126bad2cb8d5c38296307bede64bf549365acf';
/// See also [SessionService].
@ProviderFor(SessionService)
final sessionServiceProvider =
AutoDisposeNotifierProvider<SessionService, bool>.internal(
SessionService.new,
name: r'sessionServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$sessionServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SessionService = AutoDisposeNotifier<bool>;
// 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,344 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/core/routing/routes.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/domain/services/create_tab.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/services/session.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/app_bar_title.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/tabs_action_button.dart';
import 'package:kagi_bang_bang/features/settings/data/repositories/settings_repository.dart';
import 'package:kagi_bang_bang/features/web_view/domain/repositories/web_view.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/presentation/hooks/listenable_callback.dart';
import 'package:kagi_bang_bang/presentation/hooks/overlay_portal_controller.dart';
import 'package:kagi_bang_bang/presentation/widgets/animated_indexed_stack.dart';
import 'package:kagi_bang_bang/utils/ui_helper.dart' as ui_helper;
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class KagiScreen extends HookConsumerWidget {
const KagiScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayedSheet = ref.watch(bottomSheetProvider);
final displayedOverlayDialog = ref.watch(overlayDialogProvider);
final lastBackButtonPress = useRef<DateTime?>(null);
final webViewController = useRef<InAppWebViewController?>(null);
final overlayController = useOverlayPortalController();
ref.listen(
overlayDialogProvider,
(previous, next) {
if (next != null) {
overlayController.show();
} else {
overlayController.hide();
}
},
);
ref.listen(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.kagiSession),
(previous, next) async {
if (next != null && next.isNotEmpty) {
await ref.read(sessionServiceProvider.notifier).setKagiSession(next);
}
},
);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: AppBarTitle(
onTap: () {
final page = ref.read(webViewTabControllerProvider)?.page.value;
if (page != null) {
ref.watch(overlayDialogProvider.notifier).show(
WebPageDialog(
page: page,
webViewController: webViewController.value,
onDismiss:
ref.watch(overlayDialogProvider.notifier).dismiss,
),
);
}
},
),
actions: [
TabsActionButton(
onTap: () {
if (displayedSheet case ViewTabs()) {
ref.read(bottomSheetProvider.notifier).dismiss();
} else {
ref.read(bottomSheetProvider.notifier).show(ViewTabs());
}
},
),
MenuAnchor(
builder: (context, controller, child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert),
);
},
menuChildren: [
HookConsumer(
builder: (context, ref, child) {
final activeWebView = ref.watch(webViewTabControllerProvider);
final history = useListenableSelector(
activeWebView?.page,
() =>
activeWebView?.page.value.pageHistory ??
(canGoBack: false, canGoForward: false),
);
return Row(
children: [
Expanded(
child: IconButton(
onPressed: (history.canGoBack)
? () async {
await webViewController.value?.goBack();
}
: null,
icon: const Icon(Icons.arrow_back),
),
),
const SizedBox(height: 48, child: VerticalDivider()),
Expanded(
child: IconButton(
onPressed: (history.canGoForward)
? () async {
await webViewController.value?.goForward();
}
: null,
icon: const Icon(Icons.arrow_forward),
),
),
],
);
},
),
const Divider(),
MenuItemButton(
onPressed: () async {
await webViewController.value?.reload();
},
leadingIcon: const Icon(Icons.refresh),
child: const Text('Reload'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
await context.push(SettingsRoute().location);
},
leadingIcon: const Icon(Icons.settings),
child: const Text('Settings'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
final url = await webViewController.value?.getUrl();
if (url != null) {
await Share.shareUri(url);
}
},
leadingIcon: const Icon(Icons.share),
child: const Text('Share'),
),
MenuItemButton(
onPressed: () async {
final url = await webViewController.value?.getUrl();
if (url != null) {
if (!await launchUrl(
url,
mode: LaunchMode.externalApplication,
)) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Could not launch URL ($url)',
);
}
}
}
},
leadingIcon: const Icon(Icons.open_in_browser),
child: const Text('Launch External'),
),
const Divider(),
MenuItemButton(
onPressed: () {
ref.read(createTabStreamProvider.notifier).createTab(
CreateTab(preferredTool: KagiTool.search),
);
},
leadingIcon: const Icon(MdiIcons.searchWeb),
child: const Text('Search'),
),
MenuItemButton(
onPressed: () {
ref.read(createTabStreamProvider.notifier).createTab(
CreateTab(preferredTool: KagiTool.assistant),
);
},
leadingIcon: const Icon(MdiIcons.brain),
child: const Text('Assistant'),
),
MenuItemButton(
onPressed: () {
ref.read(createTabStreamProvider.notifier).createTab(
CreateTab(preferredTool: KagiTool.summarizer),
);
},
leadingIcon: const Icon(MdiIcons.text),
child: const Text('Summarizer'),
),
],
),
],
),
body: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return displayedOverlayDialog!;
},
child: Listener(
onPointerDown: (displayedSheet != null)
? (_) {
ref.read(bottomSheetProvider.notifier).dismiss();
}
: null,
child: HookConsumer(
builder: (context, ref, child) {
final webViews = ref.watch(webViewRepositoryProvider);
final activeWebView = ref.watch(webViewTabControllerProvider);
useListenableCallback(activeWebView?.page, () {
webViewController.value = activeWebView?.page.value.controller;
});
return BackButtonListener(
onBackButtonPressed: () async {
if (activeWebView?.page.value.pageHistory.canGoBack == true) {
await activeWebView?.page.value.controller?.goBack();
return true;
}
if (lastBackButtonPress.value != null &&
lastBackButtonPress.value!.difference(DateTime.now()) <
const Duration(seconds: 2)) {
lastBackButtonPress.value = null;
return false;
} else {
lastBackButtonPress.value = DateTime.now();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please click BACK again to exit'),
),
);
return true;
}
},
child: AnimatedIndexedStack(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation, secondaryAnimation) =>
SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
),
key: ValueKey(
(activeWebView != null)
? webViews.keys
.toList()
.indexOf(activeWebView.page.value.key)
: null,
),
index: (activeWebView != null)
? webViews.keys
.toList()
.indexOf(activeWebView.page.value.key)
: null,
children: webViews.values.toList(),
),
);
},
),
),
),
bottomSheet: (displayedSheet != null)
? NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
if (notification.extent <= 0.1) {
ref.read(bottomSheetProvider.notifier).dismiss();
return true;
}
return false;
},
child: switch (displayedSheet) {
ViewTabs() => DraggableScrollableSheet(
expand: false,
minChildSize: 0.1,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: ViewTabsSheet(
onClose: () {
ref.read(bottomSheetProvider.notifier).dismiss();
},
),
);
},
),
final CreateTab parameter => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.8,
minChildSize: 0.1,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: SharedContentSheet(
key: ObjectKey(parameter),
parameter: parameter,
onSubmit: (url) async {
await ref
.read(switchNewTabControllerProvider.notifier)
.add(url);
ref.read(bottomSheetProvider.notifier).dismiss();
},
),
);
},
),
},
)
: null,
);
}
}
@@ -0,0 +1,96 @@
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:hooks_riverpod/hooks_riverpod.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/favicon.dart';
import 'package:text_scroll/text_scroll.dart';
class AppBarTitle extends HookConsumerWidget {
final void Function()? onTap;
const AppBarTitle({this.onTap, super.key});
Icon _securityStatusIcon(BuildContext context, WebViewPage page) {
if (page.url.isScheme('http')) {
return Icon(
MdiIcons.lockOff,
color: Theme.of(context).colorScheme.error,
size: 14,
);
} else if (page.sslError != null) {
return Icon(
MdiIcons.lockAlert,
color: Theme.of(context).colorScheme.errorContainer,
size: 14,
);
} else {
return const Icon(
MdiIcons.lock,
size: 14,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeWebView = ref.watch(webViewTabControllerProvider);
if (activeWebView == null) {
return SizedBox.shrink();
}
final page = useValueListenable(activeWebView.page);
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Row(
children: [
FaviconImage(
webPageInfo: page,
),
const SizedBox(
width: 8,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TextScroll(
key: ValueKey(page.title),
page.title ?? 'New Tab',
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.onSurface),
// mode: TextScrollMode.bouncing,
velocity: const Velocity(pixelsPerSecond: Offset(75, 0)),
delayBefore: const Duration(milliseconds: 500),
pauseBetween: const Duration(milliseconds: 5000),
fadedBorder: true,
fadeBorderSide: FadeBorderSide.right,
fadedBorderWidth: 0.05,
intervalSpaces: 4,
numberOfReps: 2,
),
Row(
children: [
_securityStatusIcon(context, page),
const SizedBox(
width: 4,
),
Text(
page.url.authority,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurface),
),
],
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:expandable_page_view/expandable_page_view.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:hooks_riverpod/hooks_riverpod.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/presentation/widgets/tabs/assistant_tab.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/tabs/search_tab.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/tabs/summarize_tab.dart';
import 'package:kagi_bang_bang/features/share_intent/domain/entities/shared_content.dart';
import 'package:kagi_bang_bang/presentation/hooks/sync_page_tab.dart';
typedef OnSubmitUri = void Function(Uri url);
class SharedContentSheet extends HookConsumerWidget {
final CreateTab parameter;
final OnSubmitUri onSubmit;
const SharedContentSheet({
required this.parameter,
required this.onSubmit,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedContent = useMemoized(
() => (parameter.content != null)
? SharedContent.parse(parameter.content!)
: null,
);
final tabController = useTabController(
initialLength: KagiTool.values.length,
initialIndex: parameter.preferredTool?.index ??
switch (sharedContent) {
SharedText(text: final text) => (text.length > 25)
? KagiTool.assistant.index
: KagiTool.search.index,
SharedUrl() => KagiTool.summarizer.index,
null => KagiTool.assistant.index,
},
);
final pageController = usePageController(initialPage: tabController.index);
useSyncPageWithTab(tabController, pageController);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TabBar(
controller: tabController,
tabs: const [
Tab(
icon: Icon(MdiIcons.searchWeb),
text: 'Search',
),
Tab(
icon: Icon(MdiIcons.brain),
text: 'Assistant',
),
Tab(
icon: Icon(MdiIcons.text),
text: 'Summarize',
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
child: ExpandablePageView(
controller: pageController,
children: [
SearchTab(
sharedContent: sharedContent,
onSubmit: onSubmit,
),
AssistantTab(
sharedContent: sharedContent,
onSubmit: onSubmit,
),
SummarizeTab(
sharedContent: sharedContent,
onSubmit: onSubmit,
),
],
),
),
],
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.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/controllers/switch_new_tab.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/web_view_tab.dart';
class ViewTabsSheet extends HookConsumerWidget {
final VoidCallback onClose;
const ViewTabsSheet({required this.onClose, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
onPressed: () async {
await ref
.read(switchNewTabControllerProvider.notifier)
.add(Uri.https('kagi.com'));
onClose();
},
icon: const Icon(Icons.add),
label: const Text('New Tab'),
),
TextButton.icon(
onPressed: () async {
await ref
.read(webViewRepositoryProvider.notifier)
.closeAllTabs();
},
icon: const Icon(Icons.delete),
label: const Text('Close All'),
),
],
),
Consumer(
builder: (context, ref, child) {
final tabs = ref.watch(
webViewRepositoryProvider.select((tabs) => tabs.values),
);
final activeTab = ref.watch(
webViewTabControllerProvider.select(
(webView) => webView?.page.value.key,
),
);
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 0.75,
padding: const EdgeInsets.symmetric(horizontal: 4.0),
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: 2,
children: tabs
.map(
(webView) => WebViewTab(
webView: webView,
isActive: webView.key == activeTab,
onClose: onClose,
),
)
.toList(),
);
},
),
],
);
}
}
@@ -0,0 +1,168 @@
import 'package:expandable_page_view/expandable_page_view.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/entities/modes.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/sheets/shared_content_sheet.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/presentation/hooks/sync_page_tab.dart';
class AssistantTab extends HookConsumerWidget {
final SharedContent? sharedContent;
final OnSubmitUri onSubmit;
const AssistantTab({
required this.sharedContent,
required this.onSubmit,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
useAutomaticKeepAlive();
final formKey = useMemoized(() => GlobalKey<FormState>());
final textController =
useTextEditingController(text: sharedContent?.toString());
final tabController =
useTabController(initialLength: AssistantMode.values.length);
final pageController = usePageController(initialPage: tabController.index);
useSyncPageWithTab(tabController, pageController);
final researchVariant = useState(ResearchVariant.expert);
final chatModel = useState(ChatModel.gpt4Turbo);
return Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TabBar.secondary(
controller: tabController,
tabs: const [
Tab(
text: 'Research',
icon: Icon(MdiIcons.cloudSearch),
),
Tab(
text: 'Code',
icon: Icon(MdiIcons.codeJson),
),
Tab(
text: 'Chat',
icon: Icon(MdiIcons.commentTextMultiple),
),
Tab(
text: 'Custom',
icon: Icon(MdiIcons.creation),
),
],
),
ExpandablePageView(
controller: pageController,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 12,
),
SegmentedButton<ResearchVariant>(
segments: const [
ButtonSegment(
value: ResearchVariant.expert,
icon: Icon(MdiIcons.textSearch),
label: Text('Research'),
),
ButtonSegment(
value: ResearchVariant.fast,
icon: Icon(MdiIcons.invoiceTextFast),
label: Text('Fast'),
),
],
selected: {researchVariant.value},
onSelectionChanged: (value) {
researchVariant.value = value.first;
},
),
],
),
const SizedBox.shrink(),
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 12,
),
DropdownMenu<ChatModel>(
initialSelection: chatModel.value,
expandedInsets: EdgeInsets.zero,
label: const Text('Model'),
inputDecorationTheme: const InputDecorationTheme(),
dropdownMenuEntries: ChatModel.values
.map(
(model) => DropdownMenuEntry(
value: model,
label: model.label,
),
)
.toList(),
onSelected: (value) {
chatModel.value = value!;
},
),
],
),
const SizedBox.shrink(),
],
),
const SizedBox(
height: 12,
),
TextFormField(
controller: textController,
decoration: const InputDecoration(
// border: OutlineInputBorder(),
label: Text('Prompt'),
),
maxLines: null,
validator: (value) {
if (value?.isEmpty ?? true) {
return '';
}
return null;
},
),
const SizedBox(
height: 12,
),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
onSubmit(
uri_builder.assistantUri(
prompt: textController.text,
assistantMode: AssistantMode.values[tabController.index],
researchVariant: researchVariant.value,
chatModel: chatModel.value,
),
);
}
},
label: const Text('Submit'),
icon: const Icon(MdiIcons.invoiceTextSend),
),
),
],
),
);
}
}
@@ -0,0 +1,247 @@
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/kagi/domain/repositories/autosuggest.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/sheets/shared_content_sheet.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/presentation/widgets/autocomplete.dart';
import 'package:kagi_bang_bang/utils/ui_helper.dart' as ui_helper;
import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart';
// The default Material-style Autocomplete options.
class _AutocompleteOptions<T extends Object> extends StatelessWidget {
const _AutocompleteOptions({
super.key,
required this.displayStringForOption,
required this.onSelected,
required this.openDirection,
required this.options,
required this.maxOptionsHeight,
});
final AutocompleteOptionToString<T> displayStringForOption;
final AutocompleteOnSelected<T> onSelected;
final OptionsViewOpenDirection openDirection;
final Iterable<T> options;
final double maxOptionsHeight;
@override
Widget build(BuildContext context) {
final AlignmentDirectional optionsAlignment = switch (openDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
};
return Align(
alignment: optionsAlignment,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
reverse: switch (openDirection) {
OptionsViewOpenDirection.up => true,
OptionsViewOpenDirection.down => false,
},
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final T option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Builder(
builder: (BuildContext context) {
final bool highlight =
AutocompleteHighlightedOption.of(context) == index;
// if (highlight) {
// SchedulerBinding.instance.addPostFrameCallback(
// (Duration timeStamp) async {
// await Scrollable.ensureVisible(
// context,
// alignment: 0.5,
// );
// },
// debugLabel: 'AutocompleteOptions.ensureVisible',
// );
// }
return Container(
color: highlight ? Theme.of(context).focusColor : null,
padding: const EdgeInsets.all(16.0),
child: Text(displayStringForOption(option)),
);
},
),
);
},
),
),
),
);
}
}
class SearchTab extends HookConsumerWidget {
final SharedContent? sharedContent;
final OnSubmitUri onSubmit;
const SearchTab({
required this.sharedContent,
required this.onSubmit,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
useAutomaticKeepAlive();
final formKey = useMemoized(() => GlobalKey<FormState>());
final focusNode = useFocusNode();
final textController =
useTextEditingController(text: sharedContent?.toString());
final quickAnswer = useListenableSelector(
textController,
() => textController.text.endsWith('?'),
);
return Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 4),
Consumer(
builder: (context, ref, child) {
final optionsStream = ref.watch(autosuggestRepositoryProvider);
return ExternalResultsAutocomplete<String>(
textEditingController: textController,
optionsStream: optionsStream,
focusNode: focusNode,
optionsViewOpenDirection: OptionsViewOpenDirection.up,
displayStringForOption:
// ignore: avoid_redundant_argument_values
RawAutocomplete.defaultStringForOption,
optionsViewBuilder: (context, onSelected, options) {
return _AutocompleteOptions(
//Must match RawAutocomplete parent
displayStringForOption:
RawAutocomplete.defaultStringForOption,
onSelected: onSelected,
options: options,
//Must match RawAutocomplete parent
openDirection: OptionsViewOpenDirection.up,
maxOptionsHeight: 200.0,
);
},
onTextChanged: (textEditingValue) {
ref
.read(autosuggestRepositoryProvider.notifier)
.addQuery(textEditingValue.text);
},
fieldViewBuilder: (
context,
textEditingController,
focusNode,
onFieldSubmitted,
) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
// border: OutlineInputBorder(),
label: const Text('Query'),
suffixIcon: IconButton(
onPressed: () async {
final isServiceAvailable =
await SpeechToTextGoogleDialog.getInstance()
.showGoogleDialog(
onTextReceived: (data) {
textEditingController.text = data.toString();
},
// locale: "en-US",
);
if (!isServiceAvailable) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Service is not available',
);
}
}
},
icon: const Icon(Icons.mic),
),
),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
if (value?.isEmpty ?? true) {
return '';
}
return null;
},
onTapOutside: (event) {
focusNode.unfocus();
},
onFieldSubmitted: (value) {
if (formKey.currentState?.validate() ?? false) {
onSubmit(
uri_builder.searchUri(
searchQuery: textController.text,
),
);
}
// onFieldSubmitted();
},
);
},
);
},
),
SwitchListTile(
value: quickAnswer,
onChanged: (_) {
if (textController.text.endsWith('?')) {
textController.text = textController.text
.substring(0, textController.text.length - 1);
} else {
textController.text = '${textController.text}?';
}
},
contentPadding: EdgeInsets.zero,
title: const Text('Quick Answer'),
secondary: const Icon(MdiIcons.lightningBolt),
),
const SizedBox(
height: 12,
),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
onSubmit(
uri_builder.searchUri(
searchQuery: textController.text,
),
);
}
},
label: const Text('Search'),
icon: const Icon(MdiIcons.invoiceTextSend),
),
),
],
),
);
}
}
@@ -0,0 +1,153 @@
import 'dart:async';
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/entities/modes.dart';
import 'package:kagi_bang_bang/features/search_browser/presentation/widgets/sheets/shared_content_sheet.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/presentation/widgets/website_title_tile.dart';
import 'package:kagi_bang_bang/utils/uri_parser.dart' as uri_parser;
class SummarizeTab extends HookConsumerWidget {
final SharedContent? sharedContent;
final OnSubmitUri onSubmit;
const SummarizeTab({
required this.sharedContent,
required this.onSubmit,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
useAutomaticKeepAlive();
final formKey = useMemoized(() => GlobalKey<FormState>());
final selectedMode = useState(SummarizerMode.keyMoments);
final textController =
useTextEditingController(text: sharedContent?.toString());
return Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SegmentedButton<SummarizerMode>(
segments: const [
ButtonSegment(
value: SummarizerMode.keyMoments,
icon: Icon(MdiIcons.scriptTextKey),
label: Text('Key Moments'),
),
ButtonSegment(
value: SummarizerMode.summary,
icon: Icon(MdiIcons.invoiceTextMinus),
label: Text('Summary'),
),
],
selected: {selectedMode.value},
onSelectionChanged: (value) {
selectedMode.value = value.first;
},
),
const SizedBox(
height: 12,
),
...switch (sharedContent) {
SharedUrl() => [
TextFormField(
controller: textController,
decoration: const InputDecoration(
// border: OutlineInputBorder(),
label: Text('Document'),
),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
if (value?.isEmpty ?? true) {
return '';
}
return null;
},
),
HookBuilder(
builder: (context) {
final url =
useState(uri_parser.tryParseUrl(textController.text));
useEffect(
() {
Timer? timer;
void debounce() {
timer?.cancel();
timer = Timer(const Duration(milliseconds: 250), () {
url.value =
uri_parser.tryParseUrl(textController.text);
});
}
textController.addListener(debounce);
return () => textController.removeListener(debounce);
},
[textController],
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (url.value != null) WebsiteTitleTile(url.value!),
],
);
},
),
const SizedBox(
height: 12,
),
],
SharedText() || null => [
TextFormField(
controller: textController,
decoration: const InputDecoration(
// border: OutlineInputBorder(),
label: Text('Document'),
),
maxLines: null,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
if (value?.isEmpty ?? true) {
return '';
}
return null;
},
),
const SizedBox(
height: 12,
),
],
},
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
onSubmit(
uri_builder.summarizerUri(
document: SharedContent.parse(textController.text),
mode: selectedMode.value,
),
);
}
},
label: const Text('Summarize'),
icon: const Icon(MdiIcons.invoiceTextSend),
),
),
],
),
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/web_view/domain/repositories/web_view.dart';
class TabsActionButton extends HookConsumerWidget {
final VoidCallback onTap;
const TabsActionButton({required this.onTap, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabCount =
ref.watch(webViewRepositoryProvider.select((tabs) => tabs.length));
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 10.0,
top: 15.0,
right: 10.0,
bottom: 15.0,
),
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 2.0, color: Colors.white),
borderRadius: BorderRadius.circular(5.0),
),
constraints: const BoxConstraints(minWidth: 25.0),
child: Center(
child: Text(
tabCount.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
),
),
),
),
);
}
}
@@ -0,0 +1,54 @@
import 'package:kagi_bang_bang/features/search_browser/domain/entities/modes.dart';
import 'package:kagi_bang_bang/features/share_intent/domain/entities/shared_content.dart';
final _baseUrl = Uri.https('kagi.com');
Uri assistantUri({
required String prompt,
required AssistantMode assistantMode,
ResearchVariant? researchVariant,
ChatModel? chatModel,
}) =>
_baseUrl.replace(
pathSegments: [
'assistant',
],
queryParameters: {
'mode': assistantMode.value.toString(),
'q': prompt,
...switch (assistantMode) {
AssistantMode.research => {
'sub_mode': researchVariant!.value.toString(),
},
AssistantMode.code => {},
AssistantMode.chat => {
'sub_mode': chatModel!.value.toString(),
},
AssistantMode.custom => {},
},
},
);
Uri summarizerUri({
required SharedContent document,
required SummarizerMode mode,
}) =>
_baseUrl.replace(
pathSegments: [
'summarizer',
'index.html',
],
queryParameters: {
'summary': mode.value,
if (document case SharedUrl(url: final url)) 'url': url.toString(),
},
fragment: switch (document) {
SharedText(text: final text) => text,
_ => null,
},
);
Uri searchUri({required String searchQuery}) => _baseUrl.replace(
pathSegments: ['search'],
queryParameters: {'q': searchQuery},
);
@@ -0,0 +1,39 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
part 'settings.g.dart';
@CopyWith()
class Settings with FastEquatable {
final String? kagiSession;
final bool incognitoMode;
final bool enableJavascript;
final bool launchUrlExternal;
Settings({
required this.kagiSession,
required this.incognitoMode,
required this.enableJavascript,
required this.launchUrlExternal,
});
Settings.withDefaults({
required this.kagiSession,
bool? incognitoMode,
bool? enableJavascript,
bool? launchUrlExternal,
}) : incognitoMode = incognitoMode ?? true,
enableJavascript = enableJavascript ?? true,
launchUrlExternal = launchUrlExternal ?? false;
@override
bool get cacheHash => true;
@override
List<Object?> get hashParameters => [
kagiSession,
incognitoMode,
enableJavascript,
launchUrlExternal,
];
}
@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$SettingsCWProxy {
Settings kagiSession(String? kagiSession);
Settings incognitoMode(bool incognitoMode);
Settings enableJavascript(bool enableJavascript);
Settings launchUrlExternal(bool launchUrlExternal);
/// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support.
///
/// Usage
/// ```dart
/// Settings(...).copyWith(id: 12, name: "My name")
/// ````
Settings call({
String? kagiSession,
bool? incognitoMode,
bool? enableJavascript,
bool? launchUrlExternal,
});
}
/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfSettings.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfSettings.copyWith.fieldName(...)`
class _$SettingsCWProxyImpl implements _$SettingsCWProxy {
const _$SettingsCWProxyImpl(this._value);
final Settings _value;
@override
Settings kagiSession(String? kagiSession) => this(kagiSession: kagiSession);
@override
Settings incognitoMode(bool incognitoMode) =>
this(incognitoMode: incognitoMode);
@override
Settings enableJavascript(bool enableJavascript) =>
this(enableJavascript: enableJavascript);
@override
Settings launchUrlExternal(bool launchUrlExternal) =>
this(launchUrlExternal: launchUrlExternal);
@override
/// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support.
///
/// Usage
/// ```dart
/// Settings(...).copyWith(id: 12, name: "My name")
/// ````
Settings call({
Object? kagiSession = const $CopyWithPlaceholder(),
Object? incognitoMode = const $CopyWithPlaceholder(),
Object? enableJavascript = const $CopyWithPlaceholder(),
Object? launchUrlExternal = const $CopyWithPlaceholder(),
}) {
return Settings(
kagiSession: kagiSession == const $CopyWithPlaceholder()
? _value.kagiSession
// ignore: cast_nullable_to_non_nullable
: kagiSession as String?,
incognitoMode:
incognitoMode == const $CopyWithPlaceholder() || incognitoMode == null
? _value.incognitoMode
// ignore: cast_nullable_to_non_nullable
: incognitoMode as bool,
enableJavascript: enableJavascript == const $CopyWithPlaceholder() ||
enableJavascript == null
? _value.enableJavascript
// ignore: cast_nullable_to_non_nullable
: enableJavascript as bool,
launchUrlExternal: launchUrlExternal == const $CopyWithPlaceholder() ||
launchUrlExternal == null
? _value.launchUrlExternal
// ignore: cast_nullable_to_non_nullable
: launchUrlExternal as bool,
);
}
}
extension $SettingsCopyWith on Settings {
/// Returns a callable class that can be used as follows: `instanceOfSettings.copyWith(...)` or like so:`instanceOfSettings.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$SettingsCWProxy get copyWith => _$SettingsCWProxyImpl(this);
}
@@ -0,0 +1,72 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:kagi_bang_bang/features/settings/data/models/settings.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'settings_repository.g.dart';
typedef UpdateSettingsFunc = Settings Function(Settings currentSettings);
@Riverpod(keepAlive: true)
class SettingsRepository extends _$SettingsRepository {
static const _sessionStorageKey = 'b4ng_kagi_session';
static const _incognitoStorageKey = 'b4ng_settings_incognito';
static const _javascriptStorageKey = 'b4ng_settings_js';
static const _launchExternalStorageKey = 'b4ng_settings_launch_external';
final FlutterSecureStorage _flutterSecureStorage;
final Future<SharedPreferences> _sharedPreferences;
SettingsRepository()
: _flutterSecureStorage = const FlutterSecureStorage(),
_sharedPreferences = SharedPreferences.getInstance();
Future<void> updateSettings(UpdateSettingsFunc updateWithCurrent) async {
final oldSettings = state.value!;
final newSettings = updateWithCurrent(oldSettings);
if (oldSettings != newSettings) {
if (oldSettings.kagiSession != newSettings.kagiSession) {
await _flutterSecureStorage.write(
key: _sessionStorageKey,
value: newSettings.kagiSession,
);
}
if (newSettings.incognitoMode != oldSettings.incognitoMode) {
await _sharedPreferences.then(
(s) => s.setBool(_incognitoStorageKey, newSettings.incognitoMode),
);
}
if (newSettings.enableJavascript != oldSettings.enableJavascript) {
await _sharedPreferences.then(
(s) => s.setBool(_javascriptStorageKey, newSettings.enableJavascript),
);
}
if (newSettings.launchUrlExternal != oldSettings.launchUrlExternal) {
await _sharedPreferences.then(
(s) => s.setBool(
_launchExternalStorageKey,
newSettings.launchUrlExternal,
),
);
}
ref.invalidateSelf();
}
}
@override
FutureOr<Settings> build() async {
final sharedPreferences = await _sharedPreferences;
return Settings.withDefaults(
kagiSession: await _flutterSecureStorage.read(key: _sessionStorageKey),
incognitoMode: sharedPreferences.getBool(_incognitoStorageKey),
enableJavascript: sharedPreferences.getBool(_javascriptStorageKey),
launchUrlExternal: sharedPreferences.getBool(_launchExternalStorageKey),
);
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$settingsRepositoryHash() =>
r'1e1dc6c76bdcaf742335bf8e11a584093ec13f33';
/// See also [SettingsRepository].
@ProviderFor(SettingsRepository)
final settingsRepositoryProvider =
AsyncNotifierProvider<SettingsRepository, Settings>.internal(
SettingsRepository.new,
name: r'settingsRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$settingsRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SettingsRepository = AsyncNotifier<Settings>;
// 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,19 @@
import 'package:kagi_bang_bang/features/settings/data/repositories/settings_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'save_settings.g.dart';
@riverpod
class SaveSettingsController extends _$SaveSettingsController {
@override
FutureOr<void> build() {}
Future<void> save(UpdateSettingsFunc updateSettings) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(settingsRepositoryProvider.notifier)
.updateSettings(updateSettings),
);
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'save_settings.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$saveSettingsControllerHash() =>
r'861664dc6e526737adff645597b141c2ba9d97c1';
/// See also [SaveSettingsController].
@ProviderFor(SaveSettingsController)
final saveSettingsControllerProvider =
AutoDisposeAsyncNotifierProvider<SaveSettingsController, void>.internal(
SaveSettingsController.new,
name: r'saveSettingsControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$saveSettingsControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SaveSettingsController = 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,146 @@
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/settings/data/models/settings.dart';
import 'package:kagi_bang_bang/features/settings/data/repositories/settings_repository.dart';
import 'package:kagi_bang_bang/features/settings/presentation/controllers/save_settings.dart';
import 'package:kagi_bang_bang/features/settings/utils/session_link_extractor.dart';
import 'package:kagi_bang_bang/presentation/hooks/listenable_callback.dart';
class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final kagiSessionTextController = useTextEditingController(
text: ref.read(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.kagiSession),
),
);
final hideSessionText = useState(true);
// final isSavingSettings = ref.watch(
// saveSettingsControllerProvider.select((value) => value.isLoading),
// );
final incognitoEnabled = ref.watch(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.incognitoMode ?? false),
);
final javacsriptEnabled = ref.watch(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.enableJavascript ?? false),
);
final launchUrlExternal = ref.watch(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.launchUrlExternal ?? false),
);
useListenableCallback(kagiSessionTextController, () async {
var text = kagiSessionTextController.text;
if (Uri.tryParse(text) case final Uri uri) {
if (extractKagiSession(uri) case final String session) {
text = session;
}
}
await ref.read(saveSettingsControllerProvider.notifier).save(
(currentSettings) => currentSettings.copyWith.kagiSession(text),
);
});
ref.listen(
settingsRepositoryProvider.select(
(settings) => settings.valueOrNull?.kagiSession,
), (previous, next) {
if (next != null &&
next.isNotEmpty &&
kagiSessionTextController.text != next) {
kagiSessionTextController.text = next;
}
});
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
),
child: TextField(
controller: kagiSessionTextController,
obscureText: hideSessionText.value,
decoration: InputDecoration(
label: const Text('Kagi Session Token'),
hintText: 'https://kagi.com/search?token=...',
helperMaxLines: 2,
helperText:
'You can visit your Kagi Account settings to get your Session Link.',
suffixIcon: IconButton(
onPressed: () {
hideSessionText.value = !hideSessionText.value;
},
icon: Icon(
hideSessionText.value
? Icons.visibility
: Icons.visibility_off,
),
),
),
),
),
SwitchListTile.adaptive(
title: const Text('Incognito Mode'),
subtitle: const Text(
'Deletes all browsing data upon app restart for enhanced privacy.',
),
value: incognitoEnabled,
onChanged: (value) async {
await ref.read(saveSettingsControllerProvider.notifier).save(
(currentSettings) =>
currentSettings.copyWith.incognitoMode(value),
);
},
),
SwitchListTile.adaptive(
title: const Text('Enable JavaScript'),
subtitle: const Text(
'While turning off JavaScript boosts security, privacy, and speed, it may cause some sites to not work as intended.',
),
value: javacsriptEnabled,
onChanged: (value) async {
await ref.read(saveSettingsControllerProvider.notifier).save(
(currentSettings) =>
currentSettings.copyWith.enableJavascript(value),
);
},
),
SwitchListTile.adaptive(
title: const Text('Launch Links Externally'),
subtitle: const Text(
'Opens all links (except for kagi.com) in your default browser.',
),
value: launchUrlExternal,
onChanged: (value) async {
await ref.read(saveSettingsControllerProvider.notifier).save(
(currentSettings) =>
currentSettings.copyWith.launchUrlExternal(value),
);
},
),
],
),
),
),
);
}
}
@@ -0,0 +1,7 @@
String? extractKagiSession(Uri uri) {
if (uri.authority == 'kagi.com') {
return uri.queryParameters['token'];
}
return null;
}
@@ -0,0 +1,31 @@
import 'package:kagi_bang_bang/utils/uri_parser.dart' as uri_parser;
sealed class SharedContent {
const SharedContent();
factory SharedContent.parse(String content) {
if (uri_parser.tryParseUrl(content) case final Uri uri) {
return SharedUrl(uri);
} else {
return SharedText(content);
}
}
}
final class SharedUrl extends SharedContent {
final Uri url;
const SharedUrl(this.url);
@override
String toString() => url.toString();
}
final class SharedText extends SharedContent {
final String text;
const SharedText(this.text);
@override
String toString() => text;
}
@@ -0,0 +1,39 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:kagi_bang_bang/domain/entities/received_parameter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
part 'sharing_intent.g.dart';
@riverpod
Raw<Stream<ReceivedParameter>> sharingIntentStream(SharingIntentStreamRef ref) {
final initialStream = FlutterSharingIntent.instance
// ignore: discarded_futures
.getInitialSharing()
// ignore: discarded_futures
.then((event) async {
FlutterSharingIntent.instance.reset();
return event;
}).asStream();
return ConcatStream(
[initialStream, FlutterSharingIntent.instance.getMediaStream()],
)
.map(
(event) => event
.where(
(shared) =>
shared.type == SharedMediaType.TEXT ||
shared.type == SharedMediaType.URL,
)
.map((shared) => shared.value)
.whereNotNull()
.firstOrNull,
)
.whereNotNull()
.map((content) => ReceivedParameter(content, null));
}
@@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sharing_intent.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sharingIntentStreamHash() =>
r'6fca6ca6b4ac983397245a09cb61144cd5a4715d';
/// See also [sharingIntentStream].
@ProviderFor(sharingIntentStream)
final sharingIntentStreamProvider =
AutoDisposeProvider<Raw<Stream<ReceivedParameter>>>.internal(
sharingIntentStream,
name: r'sharingIntentStreamProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$sharingIntentStreamHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef SharingIntentStreamRef
= AutoDisposeProviderRef<Raw<Stream<ReceivedParameter>>>;
// 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,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;
}
+80 -9
View File
@@ -1,20 +1,91 @@
import 'package:flutter/material.dart';
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/core/logger.dart';
import 'package:kagi_bang_bang/core/providers.dart';
import 'package:kagi_bang_bang/features/search_browser/domain/services/session.dart';
import 'package:kagi_bang_bang/features/settings/data/repositories/settings_repository.dart';
import 'package:kagi_bang_bang/presentation/hooks/on_initialization.dart';
void main() {
runApp(const MainApp());
class _ErrorObserver extends ProviderObserver {
const _ErrorObserver();
@override
void providerDidFail(
ProviderBase<Object?> provider,
Object error,
StackTrace stackTrace,
ProviderContainer container,
) {
logger.e('Provider $provider threw $error at $stackTrace');
}
}
class MainApp extends StatelessWidget {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await HomeWidget.setAppGroupId('BANG_BANG');
// if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
// await InAppWebViewController.setWebContentsDebuggingEnabled(kDebugMode);
// }
runApp(
ProviderScope(
observers: const [_ErrorObserver()],
child: HookConsumer(
builder: (context, ref, child) {
useOnInitialization(
() async {
final settings =
await ref.read(settingsRepositoryProvider.future);
if (settings.incognitoMode) {
await ref.read(sessionServiceProvider.notifier).clearAllData();
}
if (settings.kagiSession case final String session) {
await ref
.read(sessionServiceProvider.notifier)
.setKagiSession(session);
}
ref.read(sessionServiceProvider.notifier).initializationDone();
},
);
return const MainApp();
},
),
),
);
}
class MainApp extends HookConsumerWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Text('Hello World!'),
),
Widget build(BuildContext context, WidgetRef ref) {
final isInitialized = ref.watch(sessionServiceProvider);
final router = ref.watch(routerProvider);
if (!isInitialized) {
return const Center(
child: CircularProgressIndicator(),
);
}
return MaterialApp.router(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
// colorScheme: ColorScheme.fromSeed(
// seedColor: const Color(0xFFFFB319),
// brightness: Brightness.dark,
// ),
),
routerConfig: router,
);
}
}
@@ -0,0 +1,11 @@
import 'package:kagi_bang_bang/domain/entities/web_page_info.dart';
import 'package:kagi_bang_bang/domain/services/generic_website.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'website_title.g.dart';
@Riverpod(keepAlive: true)
Future<WebPageInfo> pageInfo(PageInfoRef ref, Uri url) async {
final websiteService = ref.watch(genericWebsiteServiceProvider.notifier);
return websiteService.getInfo(url).then((value) => value.value);
}
@@ -0,0 +1,157 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'website_title.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$pageInfoHash() => r'47ebd4256eb405281b23791a114525447145baec';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [pageInfo].
@ProviderFor(pageInfo)
const pageInfoProvider = PageInfoFamily();
/// See also [pageInfo].
class PageInfoFamily extends Family<AsyncValue<WebPageInfo>> {
/// See also [pageInfo].
const PageInfoFamily();
/// See also [pageInfo].
PageInfoProvider call(
Uri url,
) {
return PageInfoProvider(
url,
);
}
@override
PageInfoProvider getProviderOverride(
covariant PageInfoProvider provider,
) {
return call(
provider.url,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'pageInfoProvider';
}
/// See also [pageInfo].
class PageInfoProvider extends FutureProvider<WebPageInfo> {
/// See also [pageInfo].
PageInfoProvider(
Uri url,
) : this._internal(
(ref) => pageInfo(
ref as PageInfoRef,
url,
),
from: pageInfoProvider,
name: r'pageInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$pageInfoHash,
dependencies: PageInfoFamily._dependencies,
allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies,
url: url,
);
PageInfoProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.url,
}) : super.internal();
final Uri url;
@override
Override overrideWith(
FutureOr<WebPageInfo> Function(PageInfoRef provider) create,
) {
return ProviderOverride(
origin: this,
override: PageInfoProvider._internal(
(ref) => create(ref as PageInfoRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
url: url,
),
);
}
@override
FutureProviderElement<WebPageInfo> createElement() {
return _PageInfoProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PageInfoProvider && other.url == url;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, url.hashCode);
return _SystemHash.finish(hash);
}
}
mixin PageInfoRef on FutureProviderRef<WebPageInfo> {
/// The parameter `url` of this provider.
Uri get url;
}
class _PageInfoProviderElement extends FutureProviderElement<WebPageInfo>
with PageInfoRef {
_PageInfoProviderElement(super.provider);
@override
Uri get url => (origin as PageInfoProvider).url;
}
// 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,12 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useListenableCallback(Listenable? listenable, void Function() callback) {
useEffect(
() {
listenable?.addListener(callback);
return () => listenable?.removeListener(callback);
},
[listenable],
);
}
@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useOnDispose(VoidCallback onDispose) {
useEffect(
() {
return onDispose;
},
const [],
);
}
@@ -0,0 +1,13 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
void useOnInitialization(FutureOr<void> Function() callback) {
useEffect(
() {
Future.microtask(callback);
return null;
},
[],
);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
OverlayPortalController useOverlayPortalController() {
return use(const _OverlayPortalControllerHook());
}
class _OverlayPortalControllerHook extends Hook<OverlayPortalController> {
const _OverlayPortalControllerHook();
@override
HookState<OverlayPortalController, Hook<OverlayPortalController>>
createState() {
return _OverlayPortalControllerHookState();
}
}
class _OverlayPortalControllerHookState
extends HookState<OverlayPortalController, _OverlayPortalControllerHook> {
late final controller = OverlayPortalController();
@override
OverlayPortalController build(BuildContext context) => controller;
@override
String get debugLabel => 'useOverlayPortalController';
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useSyncPageWithTab(
TabController tabController,
PageController pageController,
) {
useEffect(
() {
Future<void> syncPage() async {
await pageController.animateToPage(
tabController.index,
curve: Curves.linear,
duration: const Duration(milliseconds: 300),
);
}
void syncTab() {
if (!tabController.indexIsChanging) {
tabController.animateTo(pageController.page!.round());
}
}
tabController.addListener(syncPage);
pageController.addListener(syncTab);
return () {
tabController.removeListener(syncPage);
pageController.removeListener(syncTab);
};
},
[tabController, pageController],
);
}
@@ -0,0 +1,329 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// An internal representation of a child widget subtree that is a child of
/// the [AnimatedIndexedStack].
///
/// This keeps track of animation controllers, keys, and the child widget.
class _ChildEntry {
_ChildEntry({
required this.key,
required this.primaryController,
required this.secondaryController,
required this.child,
});
/// The key of this entry.
/// This is usually a [GlobalKey] to ensure that children do not lose their state.
final Key key;
/// The animation controller for the child's transition.
final AnimationController primaryController;
/// The (curved) animation being used to drive the transition.
final AnimationController secondaryController;
Widget child;
/// Release the resources used by this object.
///
/// The object is no longer usable after this method is called.
void dispose() {
primaryController.dispose();
secondaryController.dispose();
}
@override
String toString() => 'AnimatedIndexedStackEntry#${shortHash(this)}($child)';
}
enum _ChildAnimationDirection {
primaryForward,
primaryReverse,
secondaryForward,
secondaryReverse,
}
/// A Widget that shows a single child from a list of children.
/// Changing the index will animate the change of widgets according to the [transitionBuilder].
/// Removing the widget at the current index will also animate the change.
///
/// Widgets which are not currently visible will be kept alive until they are removed.
class AnimatedIndexedStack extends StatefulWidget {
const AnimatedIndexedStack({
super.key,
this.index = 0,
this.duration = const Duration(milliseconds: 300),
this.reverse = false,
required this.transitionBuilder,
this.layoutBuilder = defaultLayoutBuilder,
this.children = const [],
});
/// The index of the child to show.
///
/// If this is null, none of the children will be shown.
final int? index;
/// The duration of the transition from the old [child] value to the new one.
final Duration duration;
/// Indicates whether the new [child] will visually appear on top of or
/// underneath the old child.
final bool reverse;
/// A function that wraps a new [child] with a primary and secondary animation
/// set define how the child appears and disappears.
final Widget Function(
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) transitionBuilder;
/// A function that lays out all the children in this IndexedStack.
/// This defaults to [PageTransitionSwitcher.defaultLayoutBuilder].
final Widget Function(List<Widget> entries) layoutBuilder;
/// The child widgets of the stack.
/// Only the child at index [index] will be shown.
/// To correctly keep track of the state of child widgets, they must be given unique keys.
final List<Widget> children;
/// The default layout builder for [AnimatedIndexedStack].
/// Contains all the children in a [Stack].
static Widget defaultLayoutBuilder(List<Widget> entries) {
return Stack(
alignment: Alignment.center,
children: entries,
);
}
@override
State<AnimatedIndexedStack> createState() => _AnimatedIndexedStackState();
}
class _AnimatedIndexedStackState extends State<AnimatedIndexedStack>
with TickerProviderStateMixin {
/// All entries contained in this Stack.
/// This is built from the children list, but may also contain entries which are animating out.
List<_ChildEntry> _entries = [];
/// The entry which is currently at the top of the stack.
_ChildEntry? _currentEntry;
@override
void initState() {
super.initState();
_updateEntriesList();
}
@override
void didUpdateWidget(AnimatedIndexedStack oldWidget) {
super.didUpdateWidget(oldWidget);
_updateEntriesList();
}
/// In place operation to shift a child entry to the end of the list (the visual front).
///
/// If entry is null, this is a no-op.
void _moveToEnd(List<_ChildEntry> entries, _ChildEntry? entry) {
if (entry == null) return;
entries.remove(entry);
entries.add(entry);
}
/// Inserts an entry as last place in the list and animates it.
///
/// If entry is null, this is a no-op.
void _insertAndAnimate(
List<_ChildEntry> entries,
_ChildEntry? entry,
_ChildAnimationDirection direction,
) {
if (entry == null) return;
_moveToEnd(entries, entry);
switch (direction) {
case _ChildAnimationDirection.primaryForward:
entry.primaryController.forward(from: 0);
entry.secondaryController.value = 0;
case _ChildAnimationDirection.primaryReverse:
entry.primaryController.reverse(from: 1);
entry.secondaryController.value = 0;
case _ChildAnimationDirection.secondaryForward:
entry.primaryController.value = 1;
entry.secondaryController.forward(from: 0);
case _ChildAnimationDirection.secondaryReverse:
entry.primaryController.value = 1;
entry.secondaryController.reverse(from: 1);
}
}
/// Updates the list of child entries.
/// Ensures to order the list appropriately and animate entries in and out.
void _updateEntriesList() {
final List<_ChildEntry> entries = [];
final _ChildEntry? previousEntry = _currentEntry;
_ChildEntry? currentEntry;
Widget? currentChild;
if (widget.index != null && widget.children.isNotEmpty) {
currentChild = widget.children[widget.index!];
}
for (final child in widget.children) {
// We find the previous entry by looking for an identical child widget.
// If the children of this Stack share widget types, they must be given unique keys.
final int existingIndex =
_entries.indexWhere((entry) => Widget.canUpdate(entry.child, child));
_ChildEntry? existingEntry;
if (existingIndex != -1) {
existingEntry = _entries[existingIndex];
}
_ChildEntry entry;
if (existingEntry != null) {
// If we find an existing entry, we update its child widget and reuse it.
// This ensures it continues to use the same global key and animation controllers.
existingEntry.child = child;
existingEntry.primaryController.duration = widget.duration;
existingEntry.secondaryController.duration = widget.duration;
entry = existingEntry;
} else {
entry = _newEntry(child);
}
if (currentChild == child) {
currentEntry = entry;
}
entries.add(entry);
}
final bool hasChanged = previousEntry != currentEntry;
final bool previousWasRemoved =
previousEntry != null && !entries.contains(previousEntry);
if (hasChanged) {
if (widget.reverse) {
// When reverse is true, the new child will transition in below the
// old child while its secondary animation and the primary
// animation of the old child are running in reverse. This is similar to
// the transition associated with popping a [PageRoute] to reveal a new
// [PageRoute] below it.
_insertAndAnimate(
entries,
currentEntry,
_ChildAnimationDirection.secondaryReverse,
);
_insertAndAnimate(
entries,
previousEntry,
_ChildAnimationDirection.primaryReverse,
);
if (previousWasRemoved) {
previousEntry.primaryController.addStatusListener((status) {
if (status == AnimationStatus.dismissed) {
setState(() {
_entries.remove(previousEntry);
previousEntry.dispose();
});
}
});
}
} else {
// When reverse is false, the new child will transition in on top of the
// old child while its primary animation and the secondary
// animation of the old child are running forward. This is similar to
// the transition associated with pushing a new [PageRoute] on top of
// another.
_insertAndAnimate(
entries,
previousEntry,
_ChildAnimationDirection.secondaryForward,
);
_insertAndAnimate(
entries,
currentEntry,
_ChildAnimationDirection.primaryForward,
);
if (previousWasRemoved) {
previousEntry.secondaryController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_entries.remove(previousEntry);
previousEntry.dispose();
});
}
});
}
}
} else {
if (widget.reverse) {
_moveToEnd(entries, currentEntry);
_moveToEnd(entries, previousEntry);
} else {
_moveToEnd(entries, previousEntry);
_moveToEnd(entries, currentEntry);
}
}
setState(() {
_entries = entries;
_currentEntry = currentEntry;
});
}
_ChildEntry _newEntry(Widget child) => _ChildEntry(
key: GlobalKey(),
child: child,
primaryController: AnimationController(
duration: widget.duration,
vsync: this,
),
secondaryController: AnimationController(
duration: widget.duration,
vsync: this,
),
);
@override
void dispose() {
for (final entry in _entries) {
entry.dispose();
}
super.dispose();
}
Widget _buildChild(_ChildEntry entry) => AnimatedBuilder(
animation: Listenable.merge([
entry.primaryController,
entry.secondaryController,
]),
builder: (context, child) {
final bool isVisible = entry.primaryController.isAnimating ||
entry.secondaryController.isAnimating ||
entry == _currentEntry;
return Visibility(
visible: isVisible,
maintainState: true,
child: widget.transitionBuilder(
KeyedSubtree(
key: entry.key,
child: child!,
),
entry.primaryController,
entry.secondaryController,
),
);
},
child: entry.child,
);
@override
Widget build(BuildContext context) {
return widget.layoutBuilder(_entries.map(_buildChild).toList());
}
}
@@ -0,0 +1,436 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
_AutocompleteCallbackAction({
required super.onInvoke,
required this.isEnabledCallback,
});
// The enabled state determines whether the action will consume the
// key shortcut or let it continue on to the underlying text field.
// They should only be enabled when the options are showing so shortcuts
// can be used to navigate them.
final bool Function() isEnabledCallback;
@override
bool isEnabled(covariant T intent) => isEnabledCallback();
@override
bool consumesKey(covariant T intent) => isEnabled(intent);
}
class ExternalResultsAutocomplete<T extends Object> extends StatefulWidget {
/// Create an instance of RawAutocomplete.
///
/// [displayStringForOption], [onTextChanged] and [optionsViewBuilder] must
/// not be null.
const ExternalResultsAutocomplete({
super.key,
required this.optionsViewBuilder,
required this.onTextChanged,
required this.optionsStream,
this.optionsViewOpenDirection = OptionsViewOpenDirection.down,
this.displayStringForOption = defaultStringForOption,
this.fieldViewBuilder,
this.focusNode,
this.onSelected,
this.textEditingController,
this.initialValue,
}) : assert(
fieldViewBuilder != null ||
(key != null &&
focusNode != null &&
textEditingController != null),
'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.',
),
assert((focusNode == null) == (textEditingController == null)),
assert(
!(textEditingController != null && initialValue != null),
'textEditingController and initialValue cannot be simultaneously defined.',
);
/// {@template flutter.widgets.RawAutocomplete.fieldViewBuilder}
/// Builds the field whose input is used to get the options.
///
/// Pass the provided [TextEditingController] to the field built here so that
/// RawAutocomplete can listen for changes.
/// {@endtemplate}
///
/// If this parameter is null, then a [SizedBox.shrink] is built instead.
/// For how that pattern can be useful, see [textEditingController].
final AutocompleteFieldViewBuilder? fieldViewBuilder;
/// The [FocusNode] that is used for the text field.
///
/// {@template flutter.widgets.RawAutocomplete.split}
/// The main purpose of this parameter is to allow the use of a separate text
/// field located in another part of the widget tree instead of the text
/// field built by [fieldViewBuilder]. For example, it may be desirable to
/// place the text field in the AppBar and the options below in the main body.
///
/// When following this pattern, [fieldViewBuilder] can be omitted,
/// so that a text field is not drawn where it would normally be.
/// A separate text field can be created elsewhere, and a
/// FocusNode and TextEditingController can be passed both to that text field
/// and to RawAutocomplete.
///
/// {@tool dartpad}
/// This examples shows how to create an autocomplete widget with the text
/// field in the AppBar and the results in the main body of the app.
///
/// ** See code in examples/api/lib/widgets/autocomplete/raw_autocomplete.focus_node.0.dart **
/// {@end-tool}
/// {@endtemplate}
///
/// If this parameter is not null, then [textEditingController] must also be
/// not null.
final FocusNode? focusNode;
/// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
/// Builds the selectable options widgets from a list of options objects.
///
/// The options are displayed floating below or above the field using a
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
/// place in the widget tree as [ExternalResultsAutocomplete]. To control whether it opens
/// upward or downward, use [optionsViewOpenDirection].
///
/// In order to track which item is highlighted by keyboard navigation, the
/// resulting options will be wrapped in an inherited
/// [AutocompleteHighlightedOption] widget.
/// Inside this callback, the index of the highlighted option can be obtained
/// from [AutocompleteHighlightedOption.of] to display the highlighted option
/// with a visual highlight to indicate it will be the option selected from
/// the keyboard.
///
/// {@endtemplate}
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
/// {@template flutter.widgets.RawAutocomplete.optionsViewOpenDirection}
/// The direction in which to open the options-view overlay.
///
/// Defaults to [OptionsViewOpenDirection.down].
/// {@endtemplate}
final OptionsViewOpenDirection optionsViewOpenDirection;
/// {@template flutter.widgets.RawAutocomplete.displayStringForOption}
/// Returns the string to display in the field when the option is selected.
///
/// This is useful when using a custom T type and the string to display is
/// different than the string to search by.
///
/// If not provided, will use `option.toString()`.
/// {@endtemplate}
final AutocompleteOptionToString<T> displayStringForOption;
/// {@template flutter.widgets.RawAutocomplete.onSelected}
/// Called when an option is selected by the user.
/// {@endtemplate}
final AutocompleteOnSelected<T>? onSelected;
final FutureOr<void> Function(TextEditingValue textEditingValue)
onTextChanged;
/// The [TextEditingController] that is used for the text field.
///
/// {@macro flutter.widgets.RawAutocomplete.split}
///
/// If this parameter is not null, then [focusNode] must also be not null.
final TextEditingController? textEditingController;
/// {@template flutter.widgets.RawAutocomplete.initialValue}
/// The initial value to use for the text field.
/// {@endtemplate}
///
/// Setting the initial value does not notify [textEditingController]'s
/// listeners, and thus will not cause the options UI to appear.
///
/// This parameter is ignored if [textEditingController] is defined.
final TextEditingValue? initialValue;
final Stream<Iterable<T>> optionsStream;
/// Calls [AutocompleteFieldViewBuilder]'s onFieldSubmitted callback for the
/// RawAutocomplete widget indicated by the given [GlobalKey].
///
/// This is not typically used unless a custom field is implemented instead of
/// using [fieldViewBuilder]. In the typical case, the onFieldSubmitted
/// callback is passed via the [AutocompleteFieldViewBuilder] signature. When
/// not using fieldViewBuilder, the same callback can be called by using this
/// static method.
///
/// See also:
///
/// * [focusNode] and [textEditingController], which contain a code example
/// showing how to create a separate field outside of fieldViewBuilder.
static void onFieldSubmitted<T extends Object>(GlobalKey key) {
final _RawAutocompleteState<T> rawAutocomplete =
key.currentState! as _RawAutocompleteState<T>;
rawAutocomplete._onFieldSubmitted();
}
/// The default way to convert an option to a string in
/// [displayStringForOption].
///
/// Uses the `toString` method of the given `option`.
static String defaultStringForOption(Object? option) {
return option.toString();
}
@override
State<ExternalResultsAutocomplete<T>> createState() =>
_RawAutocompleteState<T>();
}
class _RawAutocompleteState<T extends Object>
extends State<ExternalResultsAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
final OverlayPortalController _optionsViewController =
OverlayPortalController(debugLabel: '_RawAutocompleteState');
TextEditingController? _internalTextEditingController;
TextEditingController get _textEditingController {
return widget.textEditingController ??
(_internalTextEditingController ??= TextEditingController()
..addListener(_onChangedField));
}
FocusNode? _internalFocusNode;
FocusNode get _focusNode {
return widget.focusNode ??
(_internalFocusNode ??= FocusNode()
..addListener(_updateOptionsViewVisibility));
}
late final Map<Type, CallbackAction<Intent>> _actionMap =
<Type, CallbackAction<Intent>>{
AutocompletePreviousOptionIntent:
_AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(
onInvoke: _highlightPreviousOption,
isEnabledCallback: () => _canShowOptionsView,
),
AutocompleteNextOptionIntent:
_AutocompleteCallbackAction<AutocompleteNextOptionIntent>(
onInvoke: _highlightNextOption,
isEnabledCallback: () => _canShowOptionsView,
),
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideOptions),
};
late StreamSubscription<Iterable<T>> _optionsSubscription;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
// Set the initial value to null so when this widget gets focused for the first
// time it will try to run the options view builder.
String? _lastFieldText;
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0);
static const Map<ShortcutActivator, Intent> _shortcuts =
<ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp):
AutocompletePreviousOptionIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown):
AutocompleteNextOptionIntent(),
};
bool get _canShowOptionsView =>
_focusNode.hasFocus && _selection == null && _options.isNotEmpty;
void _updateOptionsViewVisibility() {
if (_canShowOptionsView) {
_optionsViewController.show();
} else {
_optionsViewController.hide();
}
}
void _onUpateOptions(Iterable<T> options) {
final TextEditingValue value = _textEditingController.value;
_options = options;
_updateHighlight(_highlightedOptionIndex.value);
final T? selection = _selection;
if (selection != null &&
value.text != widget.displayStringForOption(selection)) {
_selection = null;
}
// Make sure the options are no longer hidden if the content of the field
// changes (ignore selection changes).
if (value.text != _lastFieldText) {
_lastFieldText = value.text;
_updateOptionsViewVisibility();
}
}
// Called when _textEditingController changes.
Future<void> _onChangedField() async {
final TextEditingValue value = _textEditingController.value;
await widget.onTextChanged(value);
}
// Called from fieldViewBuilder when the user submits the field.
void _onFieldSubmitted() {
if (_optionsViewController.isShowing) {
_select(_options.elementAt(_highlightedOptionIndex.value));
}
}
// Select the given option and update the widget.
void _select(T nextSelection) {
if (nextSelection == _selection) {
return;
}
_selection = nextSelection;
final String selectionString = widget.displayStringForOption(nextSelection);
_textEditingController.value = TextEditingValue(
selection: TextSelection.collapsed(offset: selectionString.length),
text: selectionString,
);
widget.onSelected?.call(nextSelection);
_updateOptionsViewVisibility();
}
void _updateHighlight(int newIndex) {
_highlightedOptionIndex.value =
_options.isEmpty ? 0 : newIndex % _options.length;
}
void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) {
assert(_canShowOptionsView);
_updateOptionsViewVisibility();
assert(_optionsViewController.isShowing);
_updateHighlight(_highlightedOptionIndex.value - 1);
}
void _highlightNextOption(AutocompleteNextOptionIntent intent) {
assert(_canShowOptionsView);
_updateOptionsViewVisibility();
assert(_optionsViewController.isShowing);
_updateHighlight(_highlightedOptionIndex.value + 1);
}
Object? _hideOptions(DismissIntent intent) {
if (_optionsViewController.isShowing) {
_optionsViewController.hide();
return null;
} else {
return Actions.invoke(context, intent);
}
}
Widget _buildOptionsView(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
final Alignment followerAlignment =
switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
}
.resolve(textDirection);
final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.topStart,
OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart,
}
.resolve(textDirection);
return CompositedTransformFollower(
link: _optionsLayerLink,
showWhenUnlinked: false,
targetAnchor: targetAnchor,
followerAnchor: followerAlignment,
child: TextFieldTapRegion(
child: AutocompleteHighlightedOption(
highlightIndexNotifier: _highlightedOptionIndex,
child: Builder(
builder: (BuildContext context) =>
widget.optionsViewBuilder(context, _select, _options),
),
),
),
);
}
@override
void initState() {
super.initState();
final TextEditingController initialController =
widget.textEditingController ??
(_internalTextEditingController =
TextEditingController.fromValue(widget.initialValue));
initialController.addListener(_onChangedField);
widget.focusNode?.addListener(_updateOptionsViewVisibility);
_optionsSubscription = widget.optionsStream.listen(_onUpateOptions);
}
@override
void didUpdateWidget(ExternalResultsAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(
oldWidget.textEditingController,
widget.textEditingController,
)) {
oldWidget.textEditingController?.removeListener(_onChangedField);
if (oldWidget.textEditingController == null) {
_internalTextEditingController?.dispose();
_internalTextEditingController = null;
}
widget.textEditingController?.addListener(_onChangedField);
}
if (!identical(oldWidget.focusNode, widget.focusNode)) {
oldWidget.focusNode?.removeListener(_updateOptionsViewVisibility);
if (oldWidget.focusNode == null) {
_internalFocusNode?.dispose();
_internalFocusNode = null;
}
widget.focusNode?.addListener(_updateOptionsViewVisibility);
}
if (!identical(oldWidget.optionsStream, widget.optionsStream)) {
unawaited(_optionsSubscription.cancel());
_optionsSubscription = widget.optionsStream.listen(_onUpateOptions);
}
}
@override
void dispose() {
widget.textEditingController?.removeListener(_onChangedField);
_internalTextEditingController?.dispose();
widget.focusNode?.removeListener(_updateOptionsViewVisibility);
_internalFocusNode?.dispose();
_highlightedOptionIndex.dispose();
unawaited(_optionsSubscription.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final Widget fieldView = widget.fieldViewBuilder?.call(
context,
_textEditingController,
_focusNode,
_onFieldSubmitted,
) ??
const SizedBox.shrink();
return OverlayPortal.targetsRootOverlay(
controller: _optionsViewController,
overlayChildBuilder: _buildOptionsView,
child: TextFieldTapRegion(
child: Container(
key: _fieldKey,
child: Shortcuts(
shortcuts: _shortcuts,
child: Actions(
actions: _actionMap,
child: CompositedTransformTarget(
link: _optionsLayerLink,
child: fieldView,
),
),
),
),
),
);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class FailureWidget extends StatelessWidget {
const FailureWidget({
super.key,
this.title,
this.exception,
this.onRetry,
this.compact = false,
});
final String? title;
final Object? exception;
final VoidCallback? onRetry;
final bool compact;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(title ?? 'Something went wrong'),
subtitle: exception != null
? Text(exception.runtimeType.toString())
: null,
trailing: compact && onRetry != null
? IconButton.outlined(
onPressed: onRetry,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.refresh_outlined),
)
: null,
textColor: Theme.of(context).colorScheme.error,
),
if (!compact && onRetry != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onRetry,
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
label: const Text('Retry'),
icon: const Icon(Icons.refresh_outlined),
),
),
),
],
),
);
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/failure_widget.dart';
import 'package:skeletonizer/skeletonizer.dart';
class WebsiteTitleTile extends HookConsumerWidget {
final Uri url;
const WebsiteTitleTile(this.url, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final websiteTileAsync = ref.watch(pageInfoProvider(url));
return Skeletonizer(
enabled: websiteTileAsync.isLoading,
child: websiteTileAsync.when(
data: (info) {
return ListTile(
leading: FaviconImage(
webPageInfo: info,
size: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(info.title ?? 'Unknown Title'),
subtitle: Text(url.authority),
);
},
error: (error, stackTrace) {
return FailureWidget(
title: error.toString(),
onRetry: () => ref.refresh(pageInfoProvider(url)),
);
},
loading: () => const ListTile(
contentPadding: EdgeInsets.zero,
title: Bone.text(),
subtitle: Bone.text(),
),
),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'dart:collection';
class LRUCache<K, V> {
int _capacity;
final LinkedHashMap<K, V> _cache;
LRUCache(
this._capacity, {
bool Function(K, K)? equals,
int Function(K)? hashCode,
bool Function(dynamic)? isValidKey,
}) : _cache = LinkedHashMap<K, V>(
equals: equals,
hashCode: hashCode,
isValidKey: isValidKey,
);
void resize(int capacity) {
if (_capacity > capacity) {
_cache.keys.take(_capacity - capacity).forEach(_cache.remove);
}
_capacity = capacity;
}
V? get(K key) {
final value = _cache.remove(key); // Temporarily remove the item.
if (value != null) {
_cache[key] =
value; // Re-inserting the item makes it the most-recently used.
}
return value;
}
void set(K key, V value) {
if (_cache.containsKey(key)) {
_cache.remove(key); // Remove the existing item before updating.
} else if (_cache.length == _capacity) {
_cache.remove(
_cache.keys.first,
); // Explicitly remove the least recently used item if at capacity.
}
_cache[key] = value; // Inserting or updating the item.
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
bool isAndroid() {
return !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
}
bool isIOS() {
return !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
void showErrorMessage(BuildContext context, String message) {
final snackBar = SnackBar(
content: Text(
message,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
backgroundColor: Theme.of(context).colorScheme.onError,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
+12
View File
@@ -0,0 +1,12 @@
Uri? tryParseUrl(String? input) {
if (input != null) {
final uri = Uri.tryParse(input);
if (uri != null &&
uri.hasAuthority &&
(uri.isScheme('http') || uri.isScheme('https') || !uri.hasScheme)) {
return uri;
}
}
return null;
}
@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -5,6 +5,18 @@
import FlutterMacOS
import Foundation
import flutter_inappwebview_macos
import flutter_secure_storage_macos
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+1140 -7
View File
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -7,12 +7,46 @@ environment:
sdk: '>=3.4.0-190.1.beta <4.0.0'
dependencies:
animations: ^2.0.11
collection: ^1.18.0
copy_with_extension: ^5.0.4
exceptions: ^0.6.0
expandable_page_view: ^1.0.17
fast_equatable: ^1.1.0
flutter:
sdk: flutter
receive_sharing_intent: ^1.6.8
flutter_hooks: ^0.20.5
flutter_inappwebview: ^6.0.0
flutter_material_design_icons: ^1.1.7447
flutter_secure_storage: ^9.0.0
flutter_sharing_intent: ^1.1.1
go_router: ^13.2.2
home_widget: ^0.6.0
hooks_riverpod: ^2.5.1
html: ^0.15.4
http: ^1.2.1
logger: ^2.2.0
riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
rxdart: ^0.27.7
share_plus: ^8.0.3
shared_preferences: ^2.2.3
skeletonizer: ^1.1.1
speech_to_text_google_dialog: ^0.0.6
text_scroll: ^0.2.0
universal_io: ^2.2.2
url_launcher: ^6.2.5
dev_dependencies:
build_runner: ^2.4.9
copy_with_extension_gen: ^5.0.4
custom_lint: ^0.6.4
fast_equatable_lint: ^0.3.0
flutter_launcher_icons: ^0.13.1
go_router_builder: ^2.4.1
lint: ^2.3.0
riverpod_generator: ^2.4.0
riverpod_lint: ^2.3.10
flutter:
uses-material-design: true
@@ -6,6 +6,15 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
@@ -3,6 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
share_plus
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST