everything build but untested & migration not finished

This commit is contained in:
Fabian Freund
2024-10-09 13:27:42 +02:00
parent 37d2c84d7f
commit 2c71eeb670
132 changed files with 3790 additions and 2817 deletions
@@ -0,0 +1,28 @@
import 'package:fast_equatable/fast_equatable.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart';
// ignore: missing_override_of_must_be_overridden
sealed class Sheet with FastEquatable {}
class CreateTabSheet extends Sheet {
final String? content;
final KagiTool? preferredTool;
bool get hasParameters => content != null || preferredTool != null;
CreateTabSheet({this.content, this.preferredTool});
@override
bool get cacheHash => true;
@override
List<Object?> get hashParameters => [content, preferredTool];
}
class ViewTabsSheet extends Sheet {
@override
bool get cacheHash => true;
@override
List<Object?> get hashParameters => [];
}
@@ -0,0 +1,82 @@
// ignore_for_file: use_setters_to_change_properties
import 'dart:async';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/repositories/data.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
class SelectedBangTrigger extends _$SelectedBangTrigger {
void setTrigger(String trigger) {
state = trigger;
}
void clearTrigger() {
state = null;
}
@override
String? build({String? domain}) {
return null;
}
}
@Riverpod()
Stream<BangData?> selectedBangData(SelectedBangDataRef ref, {String? domain}) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
final selectedBangTrigger =
ref.watch(selectedBangTriggerProvider(domain: domain));
return repository.watchBang(selectedBangTrigger);
}
@Riverpod(keepAlive: true)
class LastUsedAssistantMode extends _$LastUsedAssistantMode {
void update(AssistantMode mode) {
state = mode;
}
@override
AssistantMode build() {
return AssistantMode.research;
}
}
@Riverpod(keepAlive: true)
class ActiveResearchVariant extends _$ActiveResearchVariant {
void update(ResearchVariant mode) {
state = mode;
}
@override
ResearchVariant build() {
return ResearchVariant.expert;
}
}
@Riverpod(keepAlive: true)
class ActiveChatModel extends _$ActiveChatModel {
void update(ChatModel model) {
state = model;
}
@override
ChatModel build() {
return ChatModel.gpt4o;
}
}
@Riverpod(keepAlive: true)
class ShowFindInPage extends _$ShowFindInPage {
void update(bool show) {
state = show;
}
@override
bool build() {
return false;
}
}
@@ -0,0 +1,368 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$selectedBangDataHash() => r'40dd3c51694394909fe83659ff3e19d51a06ec55';
/// 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 [selectedBangData].
@ProviderFor(selectedBangData)
const selectedBangDataProvider = SelectedBangDataFamily();
/// See also [selectedBangData].
class SelectedBangDataFamily extends Family<AsyncValue<BangData?>> {
/// See also [selectedBangData].
const SelectedBangDataFamily();
/// See also [selectedBangData].
SelectedBangDataProvider call({
String? domain,
}) {
return SelectedBangDataProvider(
domain: domain,
);
}
@override
SelectedBangDataProvider getProviderOverride(
covariant SelectedBangDataProvider provider,
) {
return call(
domain: provider.domain,
);
}
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'selectedBangDataProvider';
}
/// See also [selectedBangData].
class SelectedBangDataProvider extends AutoDisposeStreamProvider<BangData?> {
/// See also [selectedBangData].
SelectedBangDataProvider({
String? domain,
}) : this._internal(
(ref) => selectedBangData(
ref as SelectedBangDataRef,
domain: domain,
),
from: selectedBangDataProvider,
name: r'selectedBangDataProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$selectedBangDataHash,
dependencies: SelectedBangDataFamily._dependencies,
allTransitiveDependencies:
SelectedBangDataFamily._allTransitiveDependencies,
domain: domain,
);
SelectedBangDataProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.domain,
}) : super.internal();
final String? domain;
@override
Override overrideWith(
Stream<BangData?> Function(SelectedBangDataRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SelectedBangDataProvider._internal(
(ref) => create(ref as SelectedBangDataRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
domain: domain,
),
);
}
@override
AutoDisposeStreamProviderElement<BangData?> createElement() {
return _SelectedBangDataProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SelectedBangDataProvider && other.domain == domain;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, domain.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SelectedBangDataRef on AutoDisposeStreamProviderRef<BangData?> {
/// The parameter `domain` of this provider.
String? get domain;
}
class _SelectedBangDataProviderElement
extends AutoDisposeStreamProviderElement<BangData?>
with SelectedBangDataRef {
_SelectedBangDataProviderElement(super.provider);
@override
String? get domain => (origin as SelectedBangDataProvider).domain;
}
String _$selectedBangTriggerHash() =>
r'b0e12bc95e93d50d04f1c658230cea9f15ff0385';
abstract class _$SelectedBangTrigger extends BuildlessNotifier<String?> {
late final String? domain;
String? build({
String? domain,
});
}
/// See also [SelectedBangTrigger].
@ProviderFor(SelectedBangTrigger)
const selectedBangTriggerProvider = SelectedBangTriggerFamily();
/// See also [SelectedBangTrigger].
class SelectedBangTriggerFamily extends Family<String?> {
/// See also [SelectedBangTrigger].
const SelectedBangTriggerFamily();
/// See also [SelectedBangTrigger].
SelectedBangTriggerProvider call({
String? domain,
}) {
return SelectedBangTriggerProvider(
domain: domain,
);
}
@override
SelectedBangTriggerProvider getProviderOverride(
covariant SelectedBangTriggerProvider provider,
) {
return call(
domain: provider.domain,
);
}
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'selectedBangTriggerProvider';
}
/// See also [SelectedBangTrigger].
class SelectedBangTriggerProvider
extends NotifierProviderImpl<SelectedBangTrigger, String?> {
/// See also [SelectedBangTrigger].
SelectedBangTriggerProvider({
String? domain,
}) : this._internal(
() => SelectedBangTrigger()..domain = domain,
from: selectedBangTriggerProvider,
name: r'selectedBangTriggerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$selectedBangTriggerHash,
dependencies: SelectedBangTriggerFamily._dependencies,
allTransitiveDependencies:
SelectedBangTriggerFamily._allTransitiveDependencies,
domain: domain,
);
SelectedBangTriggerProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.domain,
}) : super.internal();
final String? domain;
@override
String? runNotifierBuild(
covariant SelectedBangTrigger notifier,
) {
return notifier.build(
domain: domain,
);
}
@override
Override overrideWith(SelectedBangTrigger Function() create) {
return ProviderOverride(
origin: this,
override: SelectedBangTriggerProvider._internal(
() => create()..domain = domain,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
domain: domain,
),
);
}
@override
NotifierProviderElement<SelectedBangTrigger, String?> createElement() {
return _SelectedBangTriggerProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SelectedBangTriggerProvider && other.domain == domain;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, domain.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SelectedBangTriggerRef on NotifierProviderRef<String?> {
/// The parameter `domain` of this provider.
String? get domain;
}
class _SelectedBangTriggerProviderElement
extends NotifierProviderElement<SelectedBangTrigger, String?>
with SelectedBangTriggerRef {
_SelectedBangTriggerProviderElement(super.provider);
@override
String? get domain => (origin as SelectedBangTriggerProvider).domain;
}
String _$lastUsedAssistantModeHash() =>
r'1255a2754a3ea5ea058d110fd9743897fe103a69';
/// See also [LastUsedAssistantMode].
@ProviderFor(LastUsedAssistantMode)
final lastUsedAssistantModeProvider =
NotifierProvider<LastUsedAssistantMode, AssistantMode>.internal(
LastUsedAssistantMode.new,
name: r'lastUsedAssistantModeProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$lastUsedAssistantModeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastUsedAssistantMode = Notifier<AssistantMode>;
String _$activeResearchVariantHash() =>
r'8c65a3f5ead6cc4f9e2af85b607b7a49b0764ee3';
/// See also [ActiveResearchVariant].
@ProviderFor(ActiveResearchVariant)
final activeResearchVariantProvider =
NotifierProvider<ActiveResearchVariant, ResearchVariant>.internal(
ActiveResearchVariant.new,
name: r'activeResearchVariantProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$activeResearchVariantHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ActiveResearchVariant = Notifier<ResearchVariant>;
String _$activeChatModelHash() => r'675f406af8b4aa92699d59e37b5805a81d03a799';
/// See also [ActiveChatModel].
@ProviderFor(ActiveChatModel)
final activeChatModelProvider =
NotifierProvider<ActiveChatModel, ChatModel>.internal(
ActiveChatModel.new,
name: r'activeChatModelProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$activeChatModelHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ActiveChatModel = Notifier<ChatModel>;
String _$showFindInPageHash() => r'7ee5703f7d0c7e8a8dd6b0849d7b1e0a41fe24d9';
/// See also [ShowFindInPage].
@ProviderFor(ShowFindInPage)
final showFindInPageProvider = NotifierProvider<ShowFindInPage, bool>.internal(
ShowFindInPage.new,
name: r'showFindInPageProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$showFindInPageHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ShowFindInPage = Notifier<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,53 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:lensai/data/models/received_parameter.dart';
import 'package:lensai/features/app_widget/domain/services/home_widget.dart';
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart';
import 'package:lensai/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 _parameterToCreateTabSheetTransformer =
StreamTransformer<ReceivedParameter, CreateTabSheet>.fromHandlers(
handleData: (parameter, sink) {
final createTab = CreateTabSheet(
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<CreateTabSheet> _streamController;
@override
Stream<CreateTabSheet> build() {
_streamController = StreamController();
ref.onDispose(() async {
await _streamController.close();
});
final sharingItentStream = ref.watch(sharingIntentStreamProvider);
final appWidgetLaunchStream = ref.watch(appWidgetLaunchStreamProvider);
return MergeStream([
sharingItentStream.transform(_parameterToCreateTabSheetTransformer),
appWidgetLaunchStream.transform(_parameterToCreateTabSheetTransformer),
_streamController.stream,
]);
}
void createTab(CreateTabSheet parameter) {
_streamController.add(parameter);
}
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_tab.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$createTabStreamHash() => r'05ad914e41e0d346e082e4638afe5fe5a04052f1';
/// See also [CreateTabStream].
@ProviderFor(CreateTabStream)
final createTabStreamProvider =
AutoDisposeStreamNotifierProvider<CreateTabStream, CreateTabSheet>.internal(
CreateTabStream.new,
name: r'createTabStreamProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$createTabStreamHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CreateTabStream = AutoDisposeStreamNotifier<CreateTabSheet>;
// 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,9 @@
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'engine_settings.g.dart';
@Riverpod(keepAlive: true)
GeckoEngineSettingsService engineSettingsService(EngineSettingsServiceRef ref) {
return GeckoEngineSettingsService();
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'engine_settings.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$engineSettingsServiceHash() =>
r'9d9abb3601a3782f1f3ef91e31049db40acbf64a';
/// See also [engineSettingsService].
@ProviderFor(engineSettingsService)
final engineSettingsServiceProvider =
Provider<GeckoEngineSettingsService>.internal(
engineSettingsService,
name: r'engineSettingsServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$engineSettingsServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef EngineSettingsServiceRef = ProviderRef<GeckoEngineSettingsService>;
// 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,94 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/entities/tab_state.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/topics/domain/providers.dart';
import 'package:lensai/features/geckoview/features/topics/domain/repositories/tab_link.dart';
class TabActionDialog extends HookConsumerWidget {
final TabState initialTab;
final void Function()? onDismiss;
const TabActionDialog({
required this.initialTab,
this.onDismiss,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(initialTab.id)) ?? initialTab;
final tabTopicId = ref.watch(
tabTopicIdProvider(initialTab.id).select((value) => value.valueOrNull),
);
final topics =
ref.watch(topicsWithCountProvider.select((value) => value.valueOrNull));
final selectedTopic =
topics?.firstWhereOrNull((topic) => topic.id == tabTopicId);
final expansionController = useExpansionTileController();
return Stack(
children: [
ModalBarrier(
color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54,
onDismiss: onDismiss,
),
SimpleDialog(
titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
contentPadding: EdgeInsets.zero,
insetPadding: const EdgeInsets.symmetric(
horizontal: 20.0,
vertical: 24.0,
),
title: ListTile(
leading: RawImage(
image: tabState.icon?.value,
width: 24,
height: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(tabState.title),
subtitle: Text(tabState.url.authority),
),
children: [
SizedBox(
//We need this to stretch the dialog, then padding from dialog is applied
width: double.maxFinite,
child: ExpansionTile(
controller: expansionController,
leading: (selectedTopic != null)
? CircleAvatar(backgroundColor: selectedTopic.color)
: null,
title: (selectedTopic != null)
? Text(selectedTopic.name ?? 'New Topic')
: const Text('Assign a Topic'),
children: topics
?.where((topic) => topic.id != tabTopicId)
.map(
(topic) => ListTile(
leading: CircleAvatar(backgroundColor: topic.color),
title: Text(topic.name ?? 'New Topic'),
onTap: () async {
await ref
.read(tabLinkRepositoryProvider.notifier)
.assignTab(initialTab.id, topic.id);
expansionController.collapse();
},
),
)
.toList() ??
[],
),
),
],
),
],
);
}
}
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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:lensai/data/models/web_page_info.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/providers.dart';
import 'package:lensai/features/bangs/presentation/widgets/site_search.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart';
import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder;
import 'package:lensai/features/settings/data/models/settings.dart';
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
import 'package:lensai/features/share_intent/domain/entities/shared_content.dart';
import 'package:lensai/presentation/widgets/failure_widget.dart';
import 'package:lensai/presentation/widgets/website_title_tile.dart';
import 'package:lensai/utils/ui_helper.dart' as ui_helper;
import 'package:lensai/utils/uri_parser.dart' as uri_parser;
import 'package:share_plus/share_plus.dart';
class WebPageDialog extends HookConsumerWidget {
final Uri url;
final WebPageInfo? precachedInfo;
final void Function()? onDismiss;
const WebPageDialog({
required this.url,
this.precachedInfo,
this.onDismiss,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final incognitoEnabled = ref.watch(
settingsRepositoryProvider.select(
(value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode,
),
);
final availableBangsAsync = ref.watch(
bangDataListProvider(
filter: (
domain: url.host,
groups: null,
categoryFilter: null,
orderMostFrequentFirst: true,
),
),
);
final availableBangCount = availableBangsAsync.valueOrNull?.length;
final formKey = useMemoized(() => GlobalKey<FormState>());
final urlTextController = useTextEditingController(text: url.toString());
return Stack(
children: [
ModalBarrier(
color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54,
onDismiss: onDismiss,
),
SimpleDialog(
titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
contentPadding: EdgeInsets.zero,
insetPadding: const EdgeInsets.symmetric(
horizontal: 20.0,
vertical: 24.0,
),
title: WebsiteTitleTile(url, precachedInfo: precachedInfo),
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,
enableIMEPersonalizedLearning: !incognitoEnabled,
decoration: InputDecoration(
labelText: 'URL',
suffixIcon: IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
await ref
.read(tabSessionProvider(null).notifier)
.loadUrl(
url: uri_parser.tryParseUrl(
urlTextController.text,
eagerParsing: true,
)!,
);
onDismiss?.call();
}
},
icon: const Icon(Icons.send),
),
),
validator: (value) {
if (uri_parser.tryParseUrl(value, eagerParsing: true) !=
null) {
return null;
}
return 'Invalid URL';
},
),
),
),
),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: availableBangsAsync.when(
data: (availableBangs) {
if (availableBangs.isEmpty) {
return const SizedBox.shrink();
}
return SiteSearch(
domain: url.host,
availableBangs: availableBangs,
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Could not load bangs',
exception: error,
),
loading: () => SiteSearch(
domain: url.host,
availableBangs: [
BangData(
websiteName: 'websiteName',
domain: 'domain',
trigger: 'trigger',
urlTemplate: 'urlTemplate',
),
],
),
),
),
if (availableBangsAsync.isLoading ||
availableBangCount == null ||
availableBangCount > 0)
const Divider(),
ListTile(
leading: const Icon(MdiIcons.contentCopy),
title: const Text('Copy address'),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: url.toString()),
);
onDismiss?.call();
},
),
ListTile(
onTap: () async {
await ui_helper.launchUrlFeedback(context, 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(tabRepositoryProvider.notifier).addTab(url: url);
onDismiss?.call();
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share link'),
onTap: () async {
await Share.shareUri(url);
onDismiss?.call();
},
),
const Divider(),
ListTile(
leading: Icon(KagiTool.summarizer.icon),
title: const Text('Summarize'),
onTap: () async {
final summarizerUrl = uri_builder.summarizerUri(
document: SharedUrl(url),
mode: SummarizerMode.keyMoments,
);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: summarizerUrl);
onDismiss?.call();
},
),
],
),
],
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:lensai/features/geckoview/domain/entities/tab_state.dart';
import 'package:text_scroll/text_scroll.dart';
class AppBarTitle extends StatelessWidget {
final TabState tab;
final void Function()? onTap;
const AppBarTitle({required this.tab, this.onTap, super.key});
Icon _securityStatusIcon(BuildContext context) {
if (tab.url.isScheme('http')) {
return Icon(
MdiIcons.lockOff,
color: Theme.of(context).colorScheme.error,
size: 14,
);
} else if (!tab.securityInfoState.secure) {
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) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Row(
children: [
RawImage(
image: tab.icon?.value,
height: 16,
width: 16,
),
const SizedBox(
width: 8,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TextScroll(
key: ValueKey(tab.title),
tab.title,
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),
const SizedBox(
width: 4,
),
Expanded(
child: Text(
tab.url.authority,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurface),
),
),
],
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class ErrorContainer extends StatelessWidget {
final Widget content;
const ErrorContainer({required this.content});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.warning,
size: 36,
color: theme.colorScheme.onErrorContainer,
),
),
Expanded(
child: content,
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/error_container.dart';
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
class LandingAction extends HookConsumerWidget {
const LandingAction({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sessionTokenAvailable = ref.watch(
settingsRepositoryProvider.select(
(value) => value.valueOrNull?.kagiSession?.isNotEmpty ?? false,
),
);
return Visibility(
visible: !sessionTokenAvailable,
child: ErrorContainer(
content: Markdown(
shrinkWrap: true,
onTapLink: (text, href, title) async {
if (href == 'settings') {
await context.push(SettingsRoute().location);
}
},
data: '## No Kagi Session Provided\n'
'Please navigate to [Settings](settings) and enter your Kagi Session Token.\n'
"You must provide a valid session in order to use Kagi's features.",
),
),
);
}
}
@@ -0,0 +1,150 @@
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/domain/services/app_initialization.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/error_container.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/landing/action.dart';
import 'package:lensai/presentation/hooks/cached_future.dart';
class LandingContent extends HookConsumerWidget {
const LandingContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textTheme = Theme.of(context).textTheme;
final descriptionAsset = useCachedFuture(
() async => rootBundle.loadString('assets/landing/description.md'),
);
final changelogAsset = useCachedFuture(
() async => rootBundle.loadString('assets/landing/changelog.md'),
);
return FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
child: Column(
children: [
Text(
'Lensai',
style:
textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'The Privacy-Focused & AI-Powered Research Browser with Kagi integration',
style: textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
FractionallySizedBox(
widthFactor: 0.5,
child: Image.asset('assets/icon/icon.png'),
),
const LandingAction(),
Consumer(
builder: (context, ref, child) {
final errors = ref.watch(
appInitializationServiceProvider
.select((result) => result.valueOrNull?.errors),
);
if (errors == null || errors.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
...errors.map(
(error) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: ErrorContainer(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Error during App Initialization!',
style: theme.textTheme.titleMedium,
),
Text(
error.message,
style: theme.textTheme.titleSmall,
),
if (error.details != null)
Text(error.details.toString()),
],
),
),
);
},
),
const SizedBox(
height: 8,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () async {
await ref
.read(
appInitializationServiceProvider.notifier,
)
.reinitialize();
},
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
label: const Text('Restart App'),
icon: const Icon(Icons.refresh_outlined),
),
),
),
],
);
},
),
Markdown(
data: descriptionAsset.data ?? '',
onTapLink: (text, href, title) async {
if (href != null) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: Uri.parse(href));
}
},
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
),
Text(
'Changelog',
style:
textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
Markdown(
data: changelogAsset.data ?? '',
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
),
],
),
);
},
);
}
}
@@ -0,0 +1,129 @@
import 'package:expandable_page_view/expandable_page_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart';
import 'package:lensai/features/kagi/presentation/tabs/assistant_tab.dart';
import 'package:lensai/features/kagi/presentation/tabs/search_tab.dart';
import 'package:lensai/features/kagi/presentation/tabs/summarize_tab.dart';
import 'package:lensai/features/settings/data/models/settings.dart';
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
import 'package:lensai/features/share_intent/domain/entities/shared_content.dart';
import 'package:lensai/presentation/hooks/sync_page_tab.dart';
typedef OnSubmitUri = void Function(Uri url);
class CreateTabSheetWidget extends HookConsumerWidget {
final CreateTabSheet parameter;
final OnSubmitUri onSubmit;
final void Function(KagiTool tool)? onActiveToolChanged;
const CreateTabSheetWidget({
required this.parameter,
required this.onSubmit,
this.onActiveToolChanged,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final showEarlyAccessFeatures = ref.watch(
settingsRepositoryProvider.select(
(value) => (value.valueOrNull ?? Settings.withDefaults())
.showEarlyAccessFeatures,
),
);
final sharedContent = useMemoized(
() => (parameter.content != null)
? SharedContent.parse(parameter.content!)
: null,
[parameter],
);
final initialIndex = useMemoized(
() =>
parameter.preferredTool?.index ??
switch (sharedContent) {
SharedText(text: final text) =>
(showEarlyAccessFeatures && text.length > 25)
? KagiTool.assistant.index
: KagiTool.search.index,
SharedUrl() => KagiTool.summarizer.index,
null => showEarlyAccessFeatures
? 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,
onIndexChanged: (index) {
onActiveToolChanged?.call(KagiTool.values[index]);
},
);
useEffect(
() {
WidgetsBinding.instance.addPostFrameCallback((_) {
onActiveToolChanged?.call(KagiTool.values[initialIndex]);
});
return null;
},
[initialIndex],
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TabBar(
controller: tabController,
tabs: [
Tab(
icon: Icon(KagiTool.search.icon),
text: 'Search',
),
Tab(
icon: Icon(KagiTool.summarizer.icon),
text: 'Summarize',
),
if (showEarlyAccessFeatures)
Tab(
icon: Icon(KagiTool.assistant.icon),
text: 'Assistant',
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
child: ExpandablePageView(
controller: pageController,
children: [
SearchTab(
sharedContent: sharedContent,
onSubmit: onSubmit,
),
SummarizeTab(
sharedContent: sharedContent,
onSubmit: onSubmit,
),
if (showEarlyAccessFeatures)
AssistantTab(
sharedContent: sharedContent,
onSubmit: onSubmit,
),
],
),
),
],
);
}
}
@@ -0,0 +1,250 @@
import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/tab_action.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tab_preview.dart';
import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart';
import 'package:lensai/features/geckoview/features/topics/domain/providers.dart';
import 'package:lensai/features/geckoview/features/topics/domain/providers/selected_topic.dart';
import 'package:lensai/features/geckoview/features/topics/domain/repositories/tab_link.dart';
import 'package:lensai/features/geckoview/features/topics/presentation/widgets/topic_chips.dart';
class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
static const _headerSize = 104.0;
final VoidCallback onClose;
_SliverHeaderDelagate({required this.onClose});
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Material(
child: Consumer(
builder: (context, ref, child) {
//Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555
return Align(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
onPressed: () async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: Uri.https('kagi.com'));
onClose();
},
icon: const Icon(Icons.add),
label: const Text('New Tab'),
),
TextButton.icon(
onPressed: () async {
final topic = ref.read(selectedTopicProvider);
await ref
.read(tabLinkRepositoryProvider.notifier)
.closeAllTabs(topic);
onClose();
},
icon: const Icon(Icons.delete),
label: const Text('Close All'),
),
],
),
TopicChips(),
const SizedBox(height: 8),
],
),
);
},
),
);
}
@override
double get minExtent => _headerSize;
@override
double get maxExtent => _headerSize;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
}
class ViewTabsSheetWidget extends HookConsumerWidget {
final ScrollController sheetScrollController;
final VoidCallback onClose;
const ViewTabsSheetWidget({
required this.onClose,
required this.sheetScrollController,
super.key,
});
double _calculateItemHeight({
required double screenWidth,
required double childAspectRatio,
required double horizontalPadding,
required double mainAxisSpacing,
required double crossAxisSpacing,
required int crossAxisCount,
}) {
final totalHorizontalPadding = horizontalPadding * 2;
final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1);
final availableWidth =
screenWidth - totalHorizontalPadding - totalCrossAxisSpacing;
final itemWidth = availableWidth / crossAxisCount;
final itemHeight = itemWidth / childAspectRatio;
final totalItemHeight = itemHeight + mainAxisSpacing;
return totalItemHeight;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return FadingScroll(
fadingSize: 25,
shaderPadding:
const EdgeInsets.only(top: _SliverHeaderDelagate._headerSize),
controller: sheetScrollController,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _SliverHeaderDelagate(onClose: onClose),
),
HookConsumer(
builder: (context, ref, child) {
final topic = ref.watch(selectedTopicProvider);
final availableTabs = ref.watch(
topicTabIdsProvider(topic)
.select((value) => value.valueOrNull ?? []),
);
final activeTab = ref.watch(selectedTabProvider);
final itemHeight = useMemoized(
() => _calculateItemHeight(
screenWidth: MediaQuery.of(context).size.width,
childAspectRatio: 0.75,
horizontalPadding: 4.0,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: 2,
),
[MediaQuery.of(context).size.width],
);
useEffect(
() {
final index = availableTabs
.indexWhere((webView) => webView == activeTab);
if (index > -1) {
final reversedIndex = availableTabs.length - 1 - index;
final offset = (reversedIndex ~/ 2) * itemHeight;
if (offset != controller.offset) {
unawaited(
controller.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
return null;
},
[],
);
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
sliver: SliverGrid.count(
//Sync values for itemHeight calculation _calculateItemHeight
childAspectRatio: 0.75,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: 2,
children: availableTabs.reversed
.map(
(tabId) => Consumer(
key: ValueKey(tabId),
builder: (context, ref, child) {
final tab = ref.watch(tabStateProvider(tabId));
return (tab != null)
? TabPreview(
tab: tab,
isActive: tabId == activeTab,
onTap: () async {
if (tabId != activeTab) {
//Close first to avoid rebuilds
onClose();
await ref
.read(
tabRepositoryProvider.notifier,
)
.selectTab(tab.id);
} else {
onClose();
}
},
onLongPress: () {
ref
.read(
overlayDialogControllerProvider
.notifier,
)
.show(
TabActionDialog(
initialTab: tab,
onDismiss: ref
.read(
overlayDialogControllerProvider
.notifier,
)
.dismiss,
),
);
},
onDelete: () async {
await ref
.read(
tabRepositoryProvider.notifier,
)
.closeTab(tab.id);
},
)
: const SizedBox.shrink();
},
),
)
.toList(),
),
);
},
),
],
);
},
);
}
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:lensai/utils/ui_helper.dart' as ui_helper;
import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart';
class SpeechToTextButton extends StatelessWidget {
final Function(dynamic data) onTextReceived;
const SpeechToTextButton({required this.onTextReceived, super.key});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () async {
final isServiceAvailable =
await SpeechToTextGoogleDialog.getInstance().showGoogleDialog(
onTextReceived: onTextReceived,
// locale: "en-US",
);
if (!isServiceAvailable) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'Service is not available',
);
}
}
},
icon: const Icon(Icons.mic),
);
}
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:lensai/features/geckoview/domain/entities/tab_state.dart';
class TabPreview extends StatelessWidget {
final TabState tab;
final bool isActive;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onDelete;
const TabPreview({
required this.tab,
required this.isActive,
this.onTap,
this.onLongPress,
this.onDelete,
super.key,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
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: onTap,
onLongPress: onLongPress,
child: Column(
children: [
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Text(
overflow: TextOverflow.ellipsis,
tab.title,
maxLines: 2,
),
),
),
IconButton(
visualDensity:
const VisualDensity(horizontal: -4.0, vertical: -4.0),
onPressed: onDelete,
icon: const Icon(Icons.close),
),
],
),
Row(
children: [
const SizedBox(
width: 6.0,
),
RawImage(
image: tab.icon?.value,
height: 16,
width: 16,
),
const SizedBox(
width: 6.0,
),
Expanded(
child: Text(
tab.url.authority,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
const SizedBox(
height: 6,
),
if (tab.thumbnail != null)
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16.0),
bottomRight: Radius.circular(16.0),
),
child: SizedBox(
width: double.infinity,
child: RawImage(
image: tab.thumbnail!.value,
fit: BoxFit.fitWidth,
),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
class TabsActionButton extends HookConsumerWidget {
final bool isActive;
final VoidCallback onTap;
const TabsActionButton({
required this.onTap,
this.isActive = false,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final tabCount = ref.watch(tabStatesProvider.select((tabs) => tabs.length));
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 15.0,
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
width: 2.0,
color: isActive
? theme.colorScheme.primary
: DefaultTextStyle.of(context).style.color!,
),
borderRadius: BorderRadius.circular(5.0),
),
constraints: const BoxConstraints(minWidth: 25.0),
child: Center(
child: Text(
tabCount.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: isActive ? theme.colorScheme.primary : null,
),
),
),
),
),
);
}
}
@@ -0,0 +1,677 @@
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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:lensai/features/geckoview/features/browser/domain/services/create_tab.dart';
import 'package:lensai/features/geckoview/features/browser/domain/services/engine_settings.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/app_bar_title.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:lensai/features/geckoview/features/controllers/bottom_sheet.dart';
import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart';
import 'package:lensai/features/geckoview/features/find_in_page/presentation/controllers/find_in_page_visibility.dart';
import 'package:lensai/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart';
import 'package:lensai/features/kagi/data/services/session.dart';
import 'package:lensai/features/settings/data/models/settings.dart';
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
import 'package:lensai/presentation/hooks/overlay_portal_controller.dart';
import 'package:lensai/utils/ui_helper.dart' as ui_helper;
import 'package:share_plus/share_plus.dart';
import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart';
class BrowserScreen extends HookConsumerWidget {
const BrowserScreen({super.key});
double _realtiveSafeArea(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return 1.0 - (mediaQuery.padding.top / mediaQuery.size.height);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayedSheet = ref.watch(bottomSheetControllerProvider);
final displayedOverlayDialog = ref.watch(overlayDialogControllerProvider);
final showEarlyAccessFeatures = ref.watch(
settingsRepositoryProvider.select(
(value) => (value.valueOrNull ?? Settings.withDefaults())
.showEarlyAccessFeatures,
),
);
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 selectedTabId =
ref.watch(selectedTabStateProvider.select((value) => value?.id));
final menuController = useMemoized(() => MenuController());
final lastBackButtonPress = useRef<DateTime?>(null);
final overlayController = useOverlayPortalController();
final activeKagiTool = useValueNotifier<KagiTool?>(null, [displayedSheet]);
ref.listen(
overlayDialogControllerProvider,
(previous, next) {
if (next != null) {
overlayController.show();
} else {
overlayController.hide();
}
},
);
ref.listen(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.enableJavascript),
(previous, next) async {
if (next != null) {
await ref.read(engineSettingsServiceProvider).javaScriptEnabled(next);
}
},
);
ref.listen(
settingsRepositoryProvider
.select((value) => value.valueOrNull?.kagiSession),
(previous, next) async {
if (next != null && next.isNotEmpty) {
await ref
.read(kagiSessionServiceProvider.notifier)
.setKagiSession(next);
}
},
);
return Scaffold(
bottomNavigationBar: BottomAppBar(
height: AppBar().preferredSize.height,
padding: EdgeInsets.zero,
child: AppBar(
automaticallyImplyLeading: false,
titleSpacing: 8.0,
title: (selectedTabId != null)
? Consumer(
builder: (context, ref, child) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
return (tabState != null)
? AppBarTitle(
tab: tabState,
onTap: () {
ref
.read(
overlayDialogControllerProvider.notifier,
)
.show(
WebPageDialog(
url: tabState.url,
precachedInfo: tabState,
onDismiss: ref
.read(
overlayDialogControllerProvider
.notifier,
)
.dismiss,
),
);
},
)
: const SizedBox.shrink();
},
)
: HookBuilder(
builder: (context) {
final activeTool = useListenableSelector(
activeKagiTool,
() {
if (displayedSheet is CreateTabSheetWidget) {
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(
CreateTabSheet(
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(
CreateTabSheet(
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(
CreateTabSheet(
preferredTool: KagiTool.assistant,
),
);
},
icon: Icon(KagiTool.assistant.icon),
),
],
);
},
),
actions: [
if (selectedTabId != null) ReaderButton(),
if (quickAction != null)
InkWell(
onTap: () async {
var tab = CreateTabSheet(
preferredTool: quickAction,
);
if (quickActionVoice) {
final completer = Completer<String>();
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 = CreateTabSheet(
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(
isActive: displayedSheet is ViewTabsSheet,
onTap: () {
if (displayedSheet case ViewTabsSheet()) {
ref.read(bottomSheetControllerProvider.notifier).dismiss();
} else {
ref
.read(bottomSheetControllerProvider.notifier)
.show(ViewTabsSheet());
}
},
),
MenuAnchor(
controller: menuController,
builder: (context, controller, child) {
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: [
MenuItemButton(
onPressed: () async {
await context.push(AboutRoute().location);
},
leadingIcon: const Icon(Icons.info),
child: const Text('About'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
await context.push(SettingsRoute().location);
},
leadingIcon: const Icon(Icons.settings),
child: const Text('Settings'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
await context.push(BangCategoriesRoute().location);
},
leadingIcon: const Icon(MdiIcons.exclamationThick),
child: const Text('Bangs'),
),
if (showEarlyAccessFeatures)
MenuItemButton(
onPressed: () async {
await context.push(ChatArchiveListRoute().location);
},
leadingIcon: const Icon(MdiIcons.archive),
child: const Text('Chat Archive'),
),
const Divider(),
if (showEarlyAccessFeatures)
MenuItemButton(
onPressed: () {
ref.read(createTabStreamProvider.notifier).createTab(
CreateTabSheet(preferredTool: KagiTool.assistant),
);
},
leadingIcon: Icon(KagiTool.assistant.icon),
child: const Text('Assistant'),
),
MenuItemButton(
onPressed: () {
ref.read(createTabStreamProvider.notifier).createTab(
CreateTabSheet(preferredTool: KagiTool.summarizer),
);
},
leadingIcon: Icon(KagiTool.summarizer.icon),
child: const Text('Summarizer'),
),
MenuItemButton(
onPressed: () {
ref.read(createTabStreamProvider.notifier).createTab(
CreateTabSheet(preferredTool: KagiTool.search),
);
},
leadingIcon: Icon(KagiTool.search.icon),
child: const Text('Search'),
),
const Divider(),
if (selectedTabId != null)
MenuItemButton(
onPressed: () async {
final tabState =
ref.read(tabStateProvider(selectedTabId));
if (tabState?.url case final Uri url) {
await ui_helper.launchUrlFeedback(context, url);
}
},
leadingIcon: const Icon(Icons.open_in_browser),
child: const Text('Launch External'),
),
if (selectedTabId != null)
MenuItemButton(
onPressed: () async {
final tabState =
ref.read(tabStateProvider(selectedTabId));
if (tabState?.url case final Uri url) {
await Share.shareUri(url);
}
},
leadingIcon: const Icon(Icons.share),
child: const Text('Share'),
),
if (selectedTabId != null) const Divider(),
if (selectedTabId != null)
MenuItemButton(
onPressed: () {
ref
.read(findInPageVisibilityControllerProvider.notifier)
.show();
},
leadingIcon: const Icon(Icons.search),
child: const Text('Find in page'),
),
if (selectedTabId != null) const Divider(),
if (selectedTabId != null)
MenuItemButton(
onPressed: () async {
final controller =
ref.read(tabSessionProvider(selectedTabId).notifier);
await controller.reload();
menuController.close();
},
leadingIcon: const Icon(Icons.refresh),
child: const Text('Reload'),
),
if (selectedTabId != null) const Divider(),
if (selectedTabId != null)
Consumer(
builder: (context, ref, child) {
final history = ref.watch(
tabStateProvider(selectedTabId)
.select((value) => value?.historyState),
);
return Row(
children: [
Expanded(
child: IconButton(
onPressed: (history?.canGoBack == true)
? () async {
final controller = ref.read(
tabSessionProvider(
selectedTabId,
).notifier,
);
await controller.goBack();
menuController.close();
}
: null,
icon: const Icon(Icons.arrow_back),
),
),
const SizedBox(height: 48, child: VerticalDivider()),
Expanded(
child: IconButton(
onPressed: (history?.canGoForward == true)
? () async {
final controller = ref.read(
tabSessionProvider(
selectedTabId,
).notifier,
);
await controller.goForward();
menuController.close();
}
: null,
icon: const Icon(Icons.arrow_forward),
),
),
],
);
},
),
],
),
],
),
),
body: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return displayedOverlayDialog!;
},
child: Listener(
onPointerDown: (displayedSheet != null)
? (_) {
ref.read(bottomSheetControllerProvider.notifier).dismiss();
}
: null,
child: HookConsumer(
builder: (context, ref, child) {
return BackButtonListener(
onBackButtonPressed: () async {
final tabState = (selectedTabId != null)
? ref.read(tabStateProvider(selectedTabId))
: null;
final tabCount =
ref.read(tabStatesProvider.select((tabs) => tabs.length));
//Don't do anything if a child route is active
if (GoRouterState.of(context).topRoute?.path !=
BrowserRoute().location) {
return false;
}
if (displayedSheet != null) {
ref.read(bottomSheetControllerProvider.notifier).dismiss();
return true;
}
if (displayedOverlayDialog != null) {
ref
.read(overlayDialogControllerProvider.notifier)
.dismiss();
return true;
}
if (tabState?.historyState.canGoBack == true) {
lastBackButtonPress.value = null;
final controller = ref.read(
tabSessionProvider(selectedTabId).notifier,
);
await controller.goBack();
return true;
}
if (lastBackButtonPress.value != null &&
DateTime.now().difference(lastBackButtonPress.value!) <
const Duration(seconds: 2)) {
lastBackButtonPress.value = null;
if (tabState != null && tabCount > 1) {
await ref
.read(tabStatesProvider.notifier)
.closeTab(tabState.id);
return true;
} else {
//Mark back as unhandled and navigator will pop
return false;
}
} else {
lastBackButtonPress.value = DateTime.now();
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(
content: (tabCount > 1)
? const Text(
'Please click BACK again to close current tab',
)
: const Text(
'Please click BACK again to exit app',
),
),
);
return true;
}
},
child: Stack(
children: [
SafeArea(
child: Stack(
children: [
const GeckoView(),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Consumer(
builder: (context, ref, child) {
final value = ref.watch(
selectedTabStateProvider.select(
(state) => state?.progress ?? 100,
),
);
return Visibility(
visible: value < 100,
child: LinearProgressIndicator(
value: value / 100,
),
);
},
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Consumer(
builder: (context, ref, child) {
final value = ref.watch(
selectedTabStateProvider.select(
(state) => EdgeInsets.only(
bottom: (state?.progress != null &&
state!.progress < 100)
? 4.0
: 0.0,
),
),
);
return FindInPageWidget(padding: value);
},
),
),
],
),
),
if (displayedSheet != null)
ModalBarrier(
color: Theme.of(context).dialogTheme.barrierColor ??
Colors.black54,
),
],
),
);
},
),
),
),
bottomSheet: (displayedSheet != null)
? NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
if (notification.extent <= 0.1) {
ref.read(bottomSheetControllerProvider.notifier).dismiss();
return true;
} else {
ref
.read(bottomSheetExtendProvider.notifier)
.add(notification.extent);
}
return false;
},
child: switch (displayedSheet) {
ViewTabsSheet() => DraggableScrollableSheet(
key: ValueKey(displayedSheet),
expand: false,
minChildSize: 0.1,
maxChildSize: _realtiveSafeArea(context),
builder: (context, scrollController) {
return ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
child: ViewTabsSheetWidget(
sheetScrollController: scrollController,
onClose: () {
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
),
);
},
),
final CreateTabSheet parameter => DraggableScrollableSheet(
key: ValueKey(displayedSheet),
expand: false,
initialChildSize: 0.8,
minChildSize: 0.1,
maxChildSize: _realtiveSafeArea(context),
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: CreateTabSheetWidget(
key: ValueKey(parameter),
parameter: parameter,
onSubmit: (url) async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: url);
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
},
onActiveToolChanged: (tool) {
activeKagiTool.value = tool;
},
),
);
},
),
},
)
: null,
);
}
}