diff --git a/app/lib/features/search_browser/domain/entities/modes.dart b/app/lib/features/search_browser/domain/entities/modes.dart index d451d6be..90772ccf 100644 --- a/app/lib/features/search_browser/domain/entities/modes.dart +++ b/app/lib/features/search_browser/domain/entities/modes.dart @@ -1,8 +1,15 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; + enum KagiTool { - search, - summarizer, + search(MdiIcons.searchWeb), + summarizer(MdiIcons.text), //Sort early access features last - assistant, + assistant(MdiIcons.brain); + + final IconData icon; + + const KagiTool(this.icon); } enum AssistantMode { diff --git a/app/lib/features/search_browser/domain/entities/sheet.dart b/app/lib/features/search_browser/domain/entities/sheet.dart index ca2de2b2..37241088 100644 --- a/app/lib/features/search_browser/domain/entities/sheet.dart +++ b/app/lib/features/search_browser/domain/entities/sheet.dart @@ -1,6 +1,8 @@ import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; +import 'package:fast_equatable/fast_equatable.dart'; -sealed class Sheet {} +// ignore: missing_override_of_must_be_overridden +sealed class Sheet with FastEquatable {} class CreateTab extends Sheet { final String? content; @@ -9,6 +11,18 @@ class CreateTab extends Sheet { bool get hasParameters => content != null || preferredTool != null; CreateTab({this.content, this.preferredTool}); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [content, preferredTool]; } -class ViewTabs extends Sheet {} +class ViewTabs extends Sheet { + @override + bool get cacheHash => true; + + @override + List get hashParameters => []; +} diff --git a/app/lib/features/search_browser/domain/providers.dart b/app/lib/features/search_browser/domain/providers.dart index 49bbcc51..ff7ba1e0 100644 --- a/app/lib/features/search_browser/domain/providers.dart +++ b/app/lib/features/search_browser/domain/providers.dart @@ -33,11 +33,8 @@ class OverlayDialog extends _$OverlayDialog { class BottomSheet extends _$BottomSheet { @override Sheet? build() { - return ref.watch( - createTabStreamProvider.select( - (value) => value.valueOrNull, - ), - ); + //Don't use select here because it will only fire on new != previous + return ref.watch(createTabStreamProvider).valueOrNull; } void show(Sheet sheet) { diff --git a/app/lib/features/search_browser/domain/providers.g.dart b/app/lib/features/search_browser/domain/providers.g.dart index f28a9c83..f898357b 100644 --- a/app/lib/features/search_browser/domain/providers.g.dart +++ b/app/lib/features/search_browser/domain/providers.g.dart @@ -172,7 +172,7 @@ final overlayDialogProvider = ); typedef _$OverlayDialog = AutoDisposeNotifier; -String _$bottomSheetHash() => r'31108d6d80d5b858024a8a8ae9ebb9c650eabe70'; +String _$bottomSheetHash() => r'4040ec50a488b4b12123bedceab6d8620e6abe3d'; /// See also [BottomSheet]. @ProviderFor(BottomSheet) diff --git a/app/lib/features/search_browser/presentation/screens/browser.dart b/app/lib/features/search_browser/presentation/screens/browser.dart index 41a5af2b..b5ddab33 100644 --- a/app/lib/features/search_browser/presentation/screens/browser.dart +++ b/app/lib/features/search_browser/presentation/screens/browser.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:animations/animations.dart'; import 'package:bang_navigator/core/routing/routes.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; @@ -26,6 +28,7 @@ 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:share_plus/share_plus.dart'; +import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart'; class KagiScreen extends HookConsumerWidget { const KagiScreen({super.key}); @@ -47,6 +50,21 @@ class KagiScreen extends HookConsumerWidget { ), ); + final quickAction = ref.watch( + settingsRepositoryProvider.select( + (value) => (value.valueOrNull ?? Settings.withDefaults()).quickAction, + ), + ); + + final quickActionVoice = ref.watch( + settingsRepositoryProvider.select( + (value) => (value.valueOrNull ?? Settings.withDefaults()) + .quickActionVoiceInput, + ), + ); + + final activeWebView = ref.watch(webViewTabControllerProvider); + final menuController = useMemoized(() => MenuController()); final lastBackButtonPress = useRef(null); @@ -54,6 +72,8 @@ class KagiScreen extends HookConsumerWidget { final overlayController = useOverlayPortalController(); + final activeKagiTool = useValueNotifier(null, [displayedSheet]); + ref.listen( overlayDialogProvider, (previous, next) { @@ -81,23 +101,130 @@ class KagiScreen extends HookConsumerWidget { padding: EdgeInsets.zero, child: AppBar( automaticallyImplyLeading: false, - title: AppBarTitle( - onTap: () { - final page = ref.read(webViewTabControllerProvider)?.page.value; + titleSpacing: 8.0, + title: (activeWebView != null) + ? AppBarTitle( + activeWebView: activeWebView, + onTap: () { + final page = activeWebView.page.value; - if (page != null) { - ref.read(overlayDialogProvider.notifier).show( - WebPageDialog( - page: page, - webViewController: webViewController.value, - onDismiss: - ref.read(overlayDialogProvider.notifier).dismiss, - ), + ref.read(overlayDialogProvider.notifier).show( + WebPageDialog( + page: page, + webViewController: webViewController.value, + onDismiss: ref + .read(overlayDialogProvider.notifier) + .dismiss, + ), + ); + }, + ) + : HookBuilder( + builder: (context) { + final activeTool = useListenableSelector( + activeKagiTool, + () { + if (displayedSheet is SharedContentSheet) { + return null; + } + + return activeKagiTool.value; + }, ); - } - }, - ), + + return Row( + children: [ + IconButton( + color: (activeTool == KagiTool.search) + ? Theme.of(context).colorScheme.primary + : null, + onPressed: () { + ref + .read(createTabStreamProvider.notifier) + .createTab( + CreateTab(preferredTool: KagiTool.search), + ); + }, + icon: Icon(KagiTool.search.icon), + ), + IconButton( + color: (activeTool == KagiTool.summarizer) + ? Theme.of(context).colorScheme.primary + : null, + onPressed: () { + ref + .read(createTabStreamProvider.notifier) + .createTab( + CreateTab(preferredTool: KagiTool.summarizer), + ); + }, + icon: Icon(KagiTool.summarizer.icon), + ), + if (showEarlyAccessFeatures) + IconButton( + color: (activeTool == KagiTool.assistant) + ? Theme.of(context).colorScheme.primary + : null, + onPressed: () { + ref + .read(createTabStreamProvider.notifier) + .createTab( + CreateTab( + preferredTool: KagiTool.assistant, + ), + ); + }, + icon: Icon(KagiTool.assistant.icon), + ), + ], + ); + }, + ), actions: [ + if (activeWebView != null && quickAction != null) + InkWell( + onTap: () async { + var tab = CreateTab( + preferredTool: quickAction, + ); + + if (quickActionVoice) { + final completer = Completer(); + + final isServiceAvailable = + await SpeechToTextGoogleDialog.getInstance() + .showGoogleDialog( + onTextReceived: (data) { + completer.complete(data.toString()); + }, + // locale: "en-US", + ); + + if (!isServiceAvailable) { + if (context.mounted) { + ui_helper.showErrorMessage( + context, + 'Service is not available', + ); + } + } + + tab = CreateTab( + preferredTool: quickAction, + content: await completer.future, + ); + } + + ref.read(createTabStreamProvider.notifier).createTab(tab); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0, + horizontal: 8.0, + ), + child: Icon(quickActionVoice ? Icons.mic : quickAction.icon), + ), + ), TabsActionButton( onTap: () { if (displayedSheet case ViewTabs()) { @@ -110,15 +237,22 @@ class KagiScreen extends HookConsumerWidget { MenuAnchor( controller: menuController, builder: (context, controller, child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(Icons.more_vert), + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: InkWell( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Padding( + padding: + EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0), + child: Icon(Icons.more_vert), + ), + ), ); }, menuChildren: [ @@ -161,7 +295,7 @@ class KagiScreen extends HookConsumerWidget { CreateTab(preferredTool: KagiTool.assistant), ); }, - leadingIcon: const Icon(MdiIcons.brain), + leadingIcon: Icon(KagiTool.assistant.icon), child: const Text('Assistant'), ), MenuItemButton( @@ -170,7 +304,7 @@ class KagiScreen extends HookConsumerWidget { CreateTab(preferredTool: KagiTool.summarizer), ); }, - leadingIcon: const Icon(MdiIcons.text), + leadingIcon: Icon(KagiTool.summarizer.icon), child: const Text('Summarizer'), ), MenuItemButton( @@ -179,7 +313,7 @@ class KagiScreen extends HookConsumerWidget { CreateTab(preferredTool: KagiTool.search), ); }, - leadingIcon: const Icon(MdiIcons.searchWeb), + leadingIcon: Icon(KagiTool.search.icon), child: const Text('Search'), ), const Divider(), @@ -213,10 +347,8 @@ class KagiScreen extends HookConsumerWidget { child: const Text('Reload'), ), const Divider(), - HookConsumer( - builder: (context, ref, child) { - final activeWebView = - ref.watch(webViewTabControllerProvider); + HookBuilder( + builder: (context) { final history = useListenableSelector( activeWebView?.page, () => @@ -272,7 +404,6 @@ class KagiScreen extends HookConsumerWidget { 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; }); @@ -391,7 +522,7 @@ class KagiScreen extends HookConsumerWidget { }, child: switch (displayedSheet) { ViewTabs() => DraggableScrollableSheet( - key: ObjectKey(displayedSheet), + key: ValueKey(displayedSheet), expand: false, minChildSize: 0.1, maxChildSize: _realtiveSafeArea(context), @@ -411,7 +542,7 @@ class KagiScreen extends HookConsumerWidget { }, ), final CreateTab parameter => DraggableScrollableSheet( - key: ObjectKey(displayedSheet), + key: ValueKey(displayedSheet), expand: false, initialChildSize: 0.8, minChildSize: 0.1, @@ -420,7 +551,7 @@ class KagiScreen extends HookConsumerWidget { return SingleChildScrollView( controller: scrollController, child: SharedContentSheet( - key: ObjectKey(parameter), + key: ValueKey(parameter), parameter: parameter, onSubmit: (url) async { await ref @@ -429,6 +560,9 @@ class KagiScreen extends HookConsumerWidget { ref.read(bottomSheetProvider.notifier).dismiss(); }, + onActiveToolChanged: (tool) { + activeKagiTool.value = tool; + }, ), ); }, diff --git a/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart b/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart index 97803fd4..8cfa78a8 100644 --- a/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart +++ b/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart @@ -1,16 +1,17 @@ import 'package:bang_navigator/features/web_view/domain/entities/web_view_page.dart'; -import 'package:bang_navigator/features/web_view/domain/repositories/web_view.dart'; import 'package:bang_navigator/features/web_view/presentation/widgets/favicon.dart'; +import 'package:bang_navigator/features/web_view/presentation/widgets/web_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:text_scroll/text_scroll.dart'; -class AppBarTitle extends HookConsumerWidget { +class AppBarTitle extends HookWidget { + final WebView activeWebView; + final void Function()? onTap; - const AppBarTitle({this.onTap, super.key}); + const AppBarTitle({required this.activeWebView, this.onTap, super.key}); Icon _securityStatusIcon(BuildContext context, WebViewPage page) { if (page.url.isScheme('http')) { @@ -34,12 +35,7 @@ class AppBarTitle extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { - final activeWebView = ref.watch(webViewTabControllerProvider); - if (activeWebView == null) { - return const SizedBox.shrink(); - } - + Widget build(BuildContext context) { final page = useValueListenable(activeWebView.page); final theme = Theme.of(context); diff --git a/app/lib/features/search_browser/presentation/widgets/landing/action.dart b/app/lib/features/search_browser/presentation/widgets/landing/action.dart index aba5ad46..c15d3a16 100644 --- a/app/lib/features/search_browser/presentation/widgets/landing/action.dart +++ b/app/lib/features/search_browser/presentation/widgets/landing/action.dart @@ -1,13 +1,8 @@ import 'package:bang_navigator/core/routing/routes.dart'; -import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; -import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; -import 'package:bang_navigator/features/search_browser/domain/services/create_tab.dart'; import 'package:bang_navigator/features/search_browser/presentation/widgets/error_container.dart'; -import 'package:bang_navigator/features/settings/data/models/settings.dart'; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.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'; @@ -21,16 +16,9 @@ class LandingAction extends HookConsumerWidget { ), ); - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .showEarlyAccessFeatures, - ), - ); - return Visibility( - visible: sessionTokenAvailable, - replacement: ErrorContainer( + visible: !sessionTokenAvailable, + child: ErrorContainer( content: Markdown( shrinkWrap: true, onTapLink: (text, href, title) async { @@ -43,40 +31,6 @@ class LandingAction extends HookConsumerWidget { "You must provide a valid session in order to use Kagi's features.", ), ), - child: Wrap( - spacing: 8.0, - alignment: WrapAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( - onPressed: () { - ref.read(createTabStreamProvider.notifier).createTab( - CreateTab(preferredTool: KagiTool.search), - ); - }, - icon: const Icon(MdiIcons.searchWeb), - label: const Text('Search'), - ), - OutlinedButton.icon( - onPressed: () { - ref.read(createTabStreamProvider.notifier).createTab( - CreateTab(preferredTool: KagiTool.summarizer), - ); - }, - icon: const Icon(MdiIcons.text), - label: const Text('Summarizer'), - ), - if (showEarlyAccessFeatures) - OutlinedButton.icon( - onPressed: () { - ref.read(createTabStreamProvider.notifier).createTab( - CreateTab(preferredTool: KagiTool.assistant), - ); - }, - icon: const Icon(MdiIcons.brain), - label: const Text('Assistant'), - ), - ], - ), ); } } diff --git a/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart b/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart index 1ba16cef..998cab71 100644 --- a/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart +++ b/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart @@ -10,7 +10,6 @@ import 'package:bang_navigator/presentation/hooks/sync_page_tab.dart'; 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'; typedef OnSubmitUri = void Function(Uri url); @@ -18,10 +17,12 @@ typedef OnSubmitUri = void Function(Uri url); class SharedContentSheet extends HookConsumerWidget { final CreateTab parameter; final OnSubmitUri onSubmit; + final void Function(KagiTool tool)? onActiveToolChanged; const SharedContentSheet({ required this.parameter, required this.onSubmit, + this.onActiveToolChanged, super.key, }); @override @@ -37,11 +38,12 @@ class SharedContentSheet extends HookConsumerWidget { () => (parameter.content != null) ? SharedContent.parse(parameter.content!) : null, + [parameter], ); - final tabController = useTabController( - initialLength: KagiTool.values.length - (showEarlyAccessFeatures ? 0 : 1), - initialIndex: parameter.preferredTool?.index ?? + final initialIndex = useMemoized( + () => + parameter.preferredTool?.index ?? switch (sharedContent) { SharedText(text: final text) => (showEarlyAccessFeatures && text.length > 25) @@ -52,10 +54,32 @@ class SharedContentSheet extends HookConsumerWidget { ? KagiTool.assistant.index : KagiTool.search.index, }, + [sharedContent], + ); + + final tabController = useTabController( + initialLength: KagiTool.values.length - (showEarlyAccessFeatures ? 0 : 1), + initialIndex: initialIndex, ); final pageController = usePageController(initialPage: tabController.index); - useSyncPageWithTab(tabController, pageController); + useSyncPageWithTab( + tabController, + pageController, + onIndexChanged: (index) { + onActiveToolChanged?.call(KagiTool.values[index]); + }, + ); + + useEffect( + () { + WidgetsBinding.instance.addPostFrameCallback((_) { + onActiveToolChanged?.call(KagiTool.values[initialIndex]); + }); + return null; + }, + [initialIndex], + ); return Column( mainAxisSize: MainAxisSize.min, @@ -63,17 +87,17 @@ class SharedContentSheet extends HookConsumerWidget { TabBar( controller: tabController, tabs: [ - const Tab( - icon: Icon(MdiIcons.searchWeb), + Tab( + icon: Icon(KagiTool.search.icon), text: 'Search', ), - const Tab( - icon: Icon(MdiIcons.text), + Tab( + icon: Icon(KagiTool.summarizer.icon), text: 'Summarize', ), if (showEarlyAccessFeatures) - const Tab( - icon: Icon(MdiIcons.brain), + Tab( + icon: Icon(KagiTool.assistant.icon), text: 'Assistant', ), ], diff --git a/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart b/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart index 55f36a8f..a687cc88 100644 --- a/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart +++ b/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart @@ -15,11 +15,9 @@ class TabsActionButton extends HookConsumerWidget { return InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.only( - left: 10.0, - top: 15.0, - right: 10.0, - bottom: 15.0, + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 15.0, ), child: Container( decoration: BoxDecoration( diff --git a/app/lib/features/settings/data/models/settings.dart b/app/lib/features/settings/data/models/settings.dart index c580b484..cc626380 100644 --- a/app/lib/features/settings/data/models/settings.dart +++ b/app/lib/features/settings/data/models/settings.dart @@ -1,4 +1,5 @@ import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/material.dart'; @@ -16,6 +17,8 @@ class Settings with FastEquatable { final bool blockHttpProtocol; final Set enableHostList; final ThemeMode themeMode; + final KagiTool? quickAction; + final bool quickActionVoiceInput; Settings({ required this.kagiSession, @@ -27,6 +30,8 @@ class Settings with FastEquatable { required this.blockHttpProtocol, required this.enableHostList, required this.themeMode, + required this.quickAction, + required this.quickActionVoiceInput, }); Settings.withDefaults({ @@ -39,6 +44,8 @@ class Settings with FastEquatable { bool? blockHttpProtocol, Set? enableHostList, ThemeMode? themeMode, + this.quickAction, + bool? quickActionVoiceInput, }) : showEarlyAccessFeatures = showEarlyAccessFeatures ?? true, incognitoMode = incognitoMode ?? true, enableJavascript = enableJavascript ?? true, @@ -46,7 +53,8 @@ class Settings with FastEquatable { enableContentBlocking = enableContentBlocking ?? true, blockHttpProtocol = blockHttpProtocol ?? false, enableHostList = enableHostList ?? {HostSource.stevenBlackUnified}, - themeMode = themeMode ?? ThemeMode.dark; + themeMode = themeMode ?? ThemeMode.dark, + quickActionVoiceInput = quickActionVoiceInput ?? false; @override bool get cacheHash => true; @@ -62,5 +70,7 @@ class Settings with FastEquatable { blockHttpProtocol, enableHostList, themeMode, + quickAction, + quickActionVoiceInput, ]; } diff --git a/app/lib/features/settings/data/models/settings.g.dart b/app/lib/features/settings/data/models/settings.g.dart index fbe88864..156bfcf8 100644 --- a/app/lib/features/settings/data/models/settings.g.dart +++ b/app/lib/features/settings/data/models/settings.g.dart @@ -25,6 +25,10 @@ abstract class _$SettingsCWProxy { Settings themeMode(ThemeMode themeMode); + Settings quickAction(KagiTool? quickAction); + + Settings quickActionVoiceInput(bool quickActionVoiceInput); + /// 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 @@ -41,6 +45,8 @@ abstract class _$SettingsCWProxy { bool? blockHttpProtocol, Set? enableHostList, ThemeMode? themeMode, + KagiTool? quickAction, + bool? quickActionVoiceInput, }); } @@ -84,6 +90,13 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { @override Settings themeMode(ThemeMode themeMode) => this(themeMode: themeMode); + @override + Settings quickAction(KagiTool? quickAction) => this(quickAction: quickAction); + + @override + Settings quickActionVoiceInput(bool quickActionVoiceInput) => + this(quickActionVoiceInput: quickActionVoiceInput); + @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. @@ -102,6 +115,8 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { Object? blockHttpProtocol = const $CopyWithPlaceholder(), Object? enableHostList = const $CopyWithPlaceholder(), Object? themeMode = const $CopyWithPlaceholder(), + Object? quickAction = const $CopyWithPlaceholder(), + Object? quickActionVoiceInput = const $CopyWithPlaceholder(), }) { return Settings( kagiSession: kagiSession == const $CopyWithPlaceholder() @@ -149,6 +164,16 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { ? _value.themeMode // ignore: cast_nullable_to_non_nullable : themeMode as ThemeMode, + quickAction: quickAction == const $CopyWithPlaceholder() + ? _value.quickAction + // ignore: cast_nullable_to_non_nullable + : quickAction as KagiTool?, + quickActionVoiceInput: + quickActionVoiceInput == const $CopyWithPlaceholder() || + quickActionVoiceInput == null + ? _value.quickActionVoiceInput + // ignore: cast_nullable_to_non_nullable + : quickActionVoiceInput as bool, ); } } diff --git a/app/lib/features/settings/data/repositories/settings_repository.dart b/app/lib/features/settings/data/repositories/settings_repository.dart index 73bada78..916cecb4 100644 --- a/app/lib/features/settings/data/repositories/settings_repository.dart +++ b/app/lib/features/settings/data/repositories/settings_repository.dart @@ -1,7 +1,6 @@ -import 'package:bang_navigator/features/content_block/data/models/host.dart'; import 'package:bang_navigator/features/settings/data/models/settings.dart'; +import 'package:bang_navigator/features/settings/utils/preference_parser.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -10,34 +9,26 @@ part 'settings_repository.g.dart'; typedef UpdateSettingsFunc = Settings Function(Settings currentSettings); -Set? _parseHostSources(List? input) => input - ?.map( - (list) => - HostSource.values.firstWhereOrNull((source) => source.name == list), - ) - .whereNotNull() - .toSet(); +enum _StorageKeys { + kagiSession('kagi_session'), + showEarlyAccessFeatures('show_early_access_features'), + incognito('enabe_incognito'), + javascript('enable_js'), + launchExternal('enable_launch_external'), + contentBlocking('enable_content_blocking'), + blockHttpProtocol('block_http'), + enableHostList('enable_host_lists'), + themeMode('theme_mode'), + quickAction('enable_quick_action'), + quickActionVoiceInput('enable_quick_action_voice_input'); -ThemeMode? _parseThemeMode(int? index) { - if (index != null && index < ThemeMode.values.length) { - return ThemeMode.values[index]; - } + final String key; - return null; + const _StorageKeys(this.key); } @Riverpod(keepAlive: true) class SettingsRepository extends _$SettingsRepository { - static const _sessionStorageKey = 'b4ng_kagi_session'; - static const _showEarlyAccessFeaturesKey = 'b4ng_show_early_access'; - static const _incognitoStorageKey = 'b4ng_settings_incognito'; - static const _javascriptStorageKey = 'b4ng_settings_js'; - static const _launchExternalStorageKey = 'b4ng_settings_launch_external'; - static const _contentBlockingStorageKey = 'b4ng_settings_content_blocking'; - static const _blockHttpProtocolStorageKey = 'b4ng_settings_block_http'; - static const _enableHostListStorageKey = 'b4ng_settings_host_lists'; - static const _themeModeStorageKey = 'b4ng_settings_theme_mode'; - final FlutterSecureStorage _flutterSecureStorage; final Future _sharedPreferences; @@ -53,7 +44,7 @@ class SettingsRepository extends _$SettingsRepository { if (oldSettings != newSettings) { if (oldSettings.kagiSession != newSettings.kagiSession) { await _flutterSecureStorage.write( - key: _sessionStorageKey, + key: _StorageKeys.kagiSession.key, value: (newSettings.kagiSession?.isNotEmpty ?? false) ? newSettings.kagiSession : null, @@ -63,28 +54,28 @@ class SettingsRepository extends _$SettingsRepository { if (newSettings.showEarlyAccessFeatures != oldSettings.showEarlyAccessFeatures) { await sharedPreferences.setBool( - _showEarlyAccessFeaturesKey, + _StorageKeys.showEarlyAccessFeatures.key, newSettings.showEarlyAccessFeatures, ); } if (newSettings.incognitoMode != oldSettings.incognitoMode) { await sharedPreferences.setBool( - _incognitoStorageKey, + _StorageKeys.incognito.key, newSettings.incognitoMode, ); } if (newSettings.enableJavascript != oldSettings.enableJavascript) { await sharedPreferences.setBool( - _javascriptStorageKey, + _StorageKeys.javascript.key, newSettings.enableJavascript, ); } if (newSettings.launchUrlExternal != oldSettings.launchUrlExternal) { await sharedPreferences.setBool( - _launchExternalStorageKey, + _StorageKeys.launchExternal.key, newSettings.launchUrlExternal, ); } @@ -92,14 +83,14 @@ class SettingsRepository extends _$SettingsRepository { if (newSettings.enableContentBlocking != oldSettings.enableContentBlocking) { await sharedPreferences.setBool( - _contentBlockingStorageKey, + _StorageKeys.contentBlocking.key, newSettings.enableContentBlocking, ); } if (newSettings.blockHttpProtocol != oldSettings.blockHttpProtocol) { await sharedPreferences.setBool( - _blockHttpProtocolStorageKey, + _StorageKeys.blockHttpProtocol.key, newSettings.blockHttpProtocol, ); } @@ -109,18 +100,38 @@ class SettingsRepository extends _$SettingsRepository { oldSettings.enableHostList, )) { await sharedPreferences.setStringList( - _enableHostListStorageKey, + _StorageKeys.enableHostList.key, newSettings.enableHostList.map((list) => list.name).toList(), ); } if (newSettings.themeMode != oldSettings.themeMode) { await sharedPreferences.setInt( - _themeModeStorageKey, + _StorageKeys.themeMode.key, newSettings.themeMode.index, ); } + if (newSettings.quickAction != oldSettings.quickAction) { + final index = newSettings.quickAction?.index; + if (index != null) { + await sharedPreferences.setInt( + _StorageKeys.quickAction.key, + index, + ); + } else { + await sharedPreferences.remove(_StorageKeys.quickAction.key); + } + } + + if (newSettings.quickActionVoiceInput != + oldSettings.quickActionVoiceInput) { + await sharedPreferences.setBool( + _StorageKeys.quickActionVoiceInput.key, + newSettings.quickActionVoiceInput, + ); + } + ref.invalidateSelf(); } } @@ -130,21 +141,27 @@ class SettingsRepository extends _$SettingsRepository { final sharedPreferences = await _sharedPreferences; return Settings.withDefaults( - kagiSession: await _flutterSecureStorage.read(key: _sessionStorageKey), + kagiSession: + await _flutterSecureStorage.read(key: _StorageKeys.kagiSession.key), showEarlyAccessFeatures: - sharedPreferences.getBool(_showEarlyAccessFeaturesKey), - incognitoMode: sharedPreferences.getBool(_incognitoStorageKey), - enableJavascript: sharedPreferences.getBool(_javascriptStorageKey), - launchUrlExternal: sharedPreferences.getBool(_launchExternalStorageKey), + sharedPreferences.getBool(_StorageKeys.showEarlyAccessFeatures.key), + incognitoMode: sharedPreferences.getBool(_StorageKeys.incognito.key), + enableJavascript: sharedPreferences.getBool(_StorageKeys.javascript.key), + launchUrlExternal: + sharedPreferences.getBool(_StorageKeys.launchExternal.key), enableContentBlocking: - sharedPreferences.getBool(_contentBlockingStorageKey), + sharedPreferences.getBool(_StorageKeys.contentBlocking.key), blockHttpProtocol: - sharedPreferences.getBool(_blockHttpProtocolStorageKey), - enableHostList: _parseHostSources( - sharedPreferences.getStringList(_enableHostListStorageKey), + sharedPreferences.getBool(_StorageKeys.blockHttpProtocol.key), + enableHostList: parseHostSources( + sharedPreferences.getStringList(_StorageKeys.enableHostList.key), ), themeMode: - _parseThemeMode(sharedPreferences.getInt(_themeModeStorageKey)), + parseThemeMode(sharedPreferences.getInt(_StorageKeys.themeMode.key)), + quickAction: + parseKagiTool(sharedPreferences.getInt(_StorageKeys.quickAction.key)), + quickActionVoiceInput: + sharedPreferences.getBool(_StorageKeys.quickActionVoiceInput.key), ); } } diff --git a/app/lib/features/settings/data/repositories/settings_repository.g.dart b/app/lib/features/settings/data/repositories/settings_repository.g.dart index 4470bc6f..38fbe235 100644 --- a/app/lib/features/settings/data/repositories/settings_repository.g.dart +++ b/app/lib/features/settings/data/repositories/settings_repository.g.dart @@ -7,7 +7,7 @@ part of 'settings_repository.dart'; // ************************************************************************** String _$settingsRepositoryHash() => - r'ac2b1ecddd82e45e901425e9e5fcb9b4b61855fa'; + r'34d3c771d6ebb17cb9aa41211e035b4d1cbf2183'; /// See also [SettingsRepository]. @ProviderFor(SettingsRepository) diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index 2784c419..acdf79bc 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -2,6 +2,7 @@ import 'package:bang_navigator/features/bangs/data/models/bang.dart'; import 'package:bang_navigator/features/bangs/domain/providers.dart'; import 'package:bang_navigator/features/bangs/domain/repositories/data.dart'; import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; import 'package:bang_navigator/features/settings/data/models/settings.dart'; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/features/settings/presentation/controllers/save_settings.dart'; @@ -243,6 +244,76 @@ class SettingsScreen extends HookConsumerWidget { const SizedBox( height: 16, ), + _buildSection(theme, 'Appearance'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomListTile( + title: 'Quick Action', + subtitle: + 'Appears in the browser app bar between website title and tab count.', + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Center( + child: SegmentedButton( + emptySelectionAllowed: true, + segments: [ + ButtonSegment( + value: KagiTool.search, + icon: Icon(KagiTool.search.icon), + label: const Text('Search'), + ), + ButtonSegment( + value: KagiTool.summarizer, + icon: Icon(KagiTool.summarizer.icon), + label: const Text('Summarizer'), + ), + ButtonSegment( + value: KagiTool.assistant, + icon: Icon(KagiTool.assistant.icon), + label: const Text('Assistant'), + ), + ], + selected: { + if (settings.quickAction != null) settings.quickAction!, + }, + onSelectionChanged: (value) async { + await ref + .read(saveSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.quickAction( + value.isNotEmpty ? value.first : null, + ), + ); + }, + ), + ), + ), + ], + ), + ), + SwitchListTile.adaptive( + title: const Text('Quick Action - STT'), + subtitle: const Text( + 'Quick option will open with speech-to-text input.', + ), + value: settings.quickActionVoiceInput, + onChanged: (settings.quickAction != null) + ? (value) async { + await ref + .read(saveSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .quickActionVoiceInput(value), + ); + } + : null, + ), _buildSection(theme, 'Content Blocking'), SwitchListTile.adaptive( title: const Text('Enable Content Blocking'), diff --git a/app/lib/features/settings/presentation/widgets/custom_list_tile.dart b/app/lib/features/settings/presentation/widgets/custom_list_tile.dart index 68450630..677b1051 100644 --- a/app/lib/features/settings/presentation/widgets/custom_list_tile.dart +++ b/app/lib/features/settings/presentation/widgets/custom_list_tile.dart @@ -8,7 +8,7 @@ class CustomListTile extends StatelessWidget { final Widget? content; final Widget? prefix; - final Widget suffix; + final Widget? suffix; const CustomListTile({ super.key, @@ -16,13 +16,15 @@ class CustomListTile extends StatelessWidget { required this.subtitle, this.content, this.prefix, - required this.suffix, + this.suffix, this.enabled = true, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final subtitleTextTheme = theme.textTheme.bodyMedium! + .copyWith(color: theme.colorScheme.onSurfaceVariant); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), @@ -44,7 +46,7 @@ class CustomListTile extends StatelessWidget { Text( subtitle, style: enabled - ? theme.textTheme.bodyMedium + ? subtitleTextTheme : theme.textTheme.bodyMedium ?.copyWith(color: theme.disabledColor), ), @@ -52,7 +54,7 @@ class CustomListTile extends StatelessWidget { ], ), ), - suffix, + if (suffix != null) suffix!, ], ), ); diff --git a/app/lib/features/settings/utils/preference_parser.dart b/app/lib/features/settings/utils/preference_parser.dart new file mode 100644 index 00000000..2c548388 --- /dev/null +++ b/app/lib/features/settings/utils/preference_parser.dart @@ -0,0 +1,28 @@ +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +Set? parseHostSources(List? input) => input + ?.map( + (list) => + HostSource.values.firstWhereOrNull((source) => source.name == list), + ) + .whereNotNull() + .toSet(); + +ThemeMode? parseThemeMode(int? index) { + if (index != null && index < ThemeMode.values.length) { + return ThemeMode.values[index]; + } + + return null; +} + +KagiTool? parseKagiTool(int? index) { + if (index != null && index < ThemeMode.values.length) { + return KagiTool.values[index]; + } + + return null; +} diff --git a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart index afaabd80..4b85a3a0 100644 --- a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart +++ b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart @@ -227,7 +227,7 @@ class WebPageDialog extends HookConsumerWidget { ), const Divider(), ListTile( - leading: const Icon(MdiIcons.text), + leading: Icon(KagiTool.summarizer.icon), title: const Text('Summarize'), onTap: () async { final url = uri_builder.summarizerUri(