improved tab & topic handling

This commit is contained in:
Fabian Freund
2024-08-28 16:00:39 +02:00
parent 95dd254484
commit 0a7e84236f
43 changed files with 2035 additions and 847 deletions
@@ -0,0 +1,92 @@
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/topics/domain/repositories/topic.dart';
import 'package:lensai/features/web_view/domain/entities/web_view_page.dart';
import 'package:lensai/features/web_view/domain/repositories/web_view.dart';
import 'package:lensai/features/web_view/presentation/widgets/favicon.dart';
class TabActionDialog extends HookConsumerWidget {
final WebViewPage 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 topics =
ref.watch(topicRepositoryProvider.select((value) => value.valueOrNull));
final selectedTopic =
topics?.firstWhereOrNull((topic) => topic.id == tabState.topicId);
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: FaviconImage(
favicon: tabState.favicon,
url: tabState.url,
size: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(tabState.title ?? 'Unknown 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 != tabState.topicId)
.map(
(topic) => ListTile(
leading: CircleAvatar(backgroundColor: topic.color),
title: Text(topic.name ?? 'New Topic'),
onTap: () async {
await ref
.read(
tabStateProvider(initialTab.id).notifier,
)
.copyWith(topicId: topic.id);
expansionController.collapse();
},
),
)
.toList() ??
[],
),
),
],
),
],
);
}
}
@@ -19,7 +19,7 @@ import 'package:lensai/features/search_browser/presentation/widgets/sheets/view_
import 'package:lensai/features/search_browser/presentation/widgets/tabs_action_button.dart';
import 'package:lensai/features/settings/data/models/settings.dart';
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart';
import 'package:lensai/features/topics/domain/repositories/tab.dart';
import 'package:lensai/features/web_view/domain/repositories/web_view.dart';
import 'package:lensai/features/web_view/presentation/controllers/readerability.dart';
import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart';
@@ -64,7 +64,7 @@ class KagiScreen extends HookConsumerWidget {
),
);
final activeWebView = ref.watch(webViewTabControllerProvider);
final activeTabId = ref.watch(webViewTabControllerProvider);
final menuController = useMemoized(() => MenuController());
@@ -102,21 +102,28 @@ class KagiScreen extends HookConsumerWidget {
child: AppBar(
automaticallyImplyLeading: false,
titleSpacing: 8.0,
title: (activeWebView != null)
? AppBarTitle(
activeWebView: activeWebView,
onTap: () {
final page = activeWebView.page.value;
title: (activeTabId != null)
? Consumer(
builder: (context, ref, child) {
final page = ref.watch(tabStateProvider(activeTabId));
ref.read(overlayDialogProvider.notifier).show(
WebPageDialog(
return (page != null)
? AppBarTitle(
page: page,
webViewController: page.controller,
onDismiss: ref
.read(overlayDialogProvider.notifier)
.dismiss,
),
);
onTap: () {
ref.read(overlayDialogProvider.notifier).show(
WebPageDialog(
url: page.url,
precachedInfo: page,
webViewController: page.controller,
onDismiss: ref
.read(overlayDialogProvider.notifier)
.dismiss,
),
);
},
)
: const SizedBox.shrink();
},
)
: HookBuilder(
@@ -181,17 +188,13 @@ class KagiScreen extends HookConsumerWidget {
},
),
actions: [
if (activeWebView != null)
if (activeTabId != null)
HookConsumer(
builder: (context, ref, child) {
final colorScheme = Theme.of(context).colorScheme;
final controller =
useListenable(activeWebView.page).value.controller;
final readerabilityState = ref.watch(
readerabilityControllerProvider(
ConsistentController(controller),
),
readerabilityControllerProvider(activeTabId),
);
final enableReadability = ref.watch(
@@ -231,24 +234,25 @@ class KagiScreen extends HookConsumerWidget {
data: (_) => Visibility(
visible: isReaderable == true || readerableApplied,
child: InkWell(
onTap: readerabilityState.isLoading
? null
: () async {
final controller =
activeWebView.currentController;
onTap: readerabilityState.isLoading
? null
: () async {
final controller = ref.read(
webViewControllerProvider(activeTabId),
);
if (controller != null) {
final readabilityNotifier = ref.read(
readerabilityControllerProvider(
ConsistentController(controller),
).notifier,
);
if (controller != null) {
final readabilityNotifier = ref.read(
readerabilityControllerProvider(
activeTabId,
).notifier,
);
await readabilityNotifier
.toggleReaderable();
}
},
child: icon,
await readabilityNotifier
.toggleReaderable();
}
},
child: icon,
),
),
error: (error, stackTrace) => SizedBox.shrink(),
@@ -271,7 +275,7 @@ class KagiScreen extends HookConsumerWidget {
);
},
),
if (activeWebView != null && quickAction != null)
if (quickAction != null)
InkWell(
onTap: () async {
var tab = CreateTab(
@@ -408,86 +412,106 @@ class KagiScreen extends HookConsumerWidget {
child: const Text('Search'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
final url =
await activeWebView?.currentController?.getUrl();
if (url != null) {
// ignore: use_build_context_synchronously
await ui_helper.launchUrlFeedback(context, url);
}
},
leadingIcon: const Icon(Icons.open_in_browser),
child: const Text('Launch External'),
),
MenuItemButton(
onPressed: () async {
final url =
await activeWebView?.currentController?.getUrl();
if (url != null) {
await Share.shareUri(url);
}
},
leadingIcon: const Icon(Icons.share),
child: const Text('Share'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
ref.read(showFindInPageProvider.notifier).update(true);
},
leadingIcon: const Icon(Icons.search),
child: const Text('Find in page'),
),
const Divider(),
MenuItemButton(
onPressed: () async {
await activeWebView?.currentController?.reload();
},
leadingIcon: const Icon(Icons.refresh),
child: const Text('Reload'),
),
const Divider(),
HookBuilder(
builder: (context) {
final history = useListenableSelector(
activeWebView?.page,
() =>
activeWebView?.page.value.pageHistory ??
(canGoBack: false, canGoForward: false),
);
if (activeTabId != null)
MenuItemButton(
onPressed: () async {
final controller =
ref.read(webViewControllerProvider(activeTabId));
return Row(
children: [
Expanded(
child: IconButton(
onPressed: (history.canGoBack)
? () async {
await activeWebView?.currentController
?.goBack();
menuController.close();
}
: null,
icon: const Icon(Icons.arrow_back),
final url = await controller?.getUrl();
if (url != null) {
// ignore: use_build_context_synchronously
await ui_helper.launchUrlFeedback(context, url);
}
},
leadingIcon: const Icon(Icons.open_in_browser),
child: const Text('Launch External'),
),
if (activeTabId != null)
MenuItemButton(
onPressed: () async {
final controller =
ref.read(webViewControllerProvider(activeTabId));
final url = await controller?.getUrl();
if (url != null) {
await Share.shareUri(url);
}
},
leadingIcon: const Icon(Icons.share),
child: const Text('Share'),
),
if (activeTabId != null) const Divider(),
if (activeTabId != null)
MenuItemButton(
onPressed: () async {
ref.read(showFindInPageProvider.notifier).update(true);
},
leadingIcon: const Icon(Icons.search),
child: const Text('Find in page'),
),
if (activeTabId != null) const Divider(),
if (activeTabId != null)
MenuItemButton(
onPressed: () async {
final controller =
ref.read(webViewControllerProvider(activeTabId));
await controller?.reload();
},
leadingIcon: const Icon(Icons.refresh),
child: const Text('Reload'),
),
if (activeTabId != null) const Divider(),
if (activeTabId != null)
Consumer(
builder: (context, ref, child) {
final history = ref.watch(
tabStateProvider(activeTabId)
.select((value) => value?.pageHistory),
);
return Row(
children: [
Expanded(
child: IconButton(
onPressed: (history?.canGoBack == true)
? () async {
final controller = ref.read(
webViewControllerProvider(
activeTabId,
),
);
await controller?.goBack();
menuController.close();
}
: null,
icon: const Icon(Icons.arrow_back),
),
),
),
const SizedBox(height: 48, child: VerticalDivider()),
Expanded(
child: IconButton(
onPressed: (history.canGoForward)
? () async {
await activeWebView?.currentController
?.goForward();
menuController.close();
}
: null,
icon: const Icon(Icons.arrow_forward),
const SizedBox(height: 48, child: VerticalDivider()),
Expanded(
child: IconButton(
onPressed: (history?.canGoForward == true)
? () async {
final controller = ref.read(
webViewControllerProvider(
activeTabId,
),
);
await controller?.goForward();
menuController.close();
}
: null,
icon: const Icon(Icons.arrow_forward),
),
),
),
],
);
},
),
],
);
},
),
],
),
],
@@ -506,10 +530,18 @@ class KagiScreen extends HookConsumerWidget {
: null,
child: HookConsumer(
builder: (context, ref, child) {
final webViews = ref.watch(webViewRepositoryProvider);
final webViewWidgets = ref.watch(webViewWidgetCacheProvider);
final activeWebViewStackIndex = (activeTabId != null)
? webViewWidgets
.indexWhere((view) => view.tabId == activeTabId)
: null;
return BackButtonListener(
onBackButtonPressed: () async {
final page = (activeTabId != null)
? ref.read(tabStateProvider(activeTabId))
: null;
//Don't do anything if a child route is active
if (GoRouterState.of(context).topRoute?.name != 'KagiRoute') {
return false;
@@ -525,10 +557,10 @@ class KagiScreen extends HookConsumerWidget {
return true;
}
if (activeWebView?.page.value.pageHistory.canGoBack == true) {
if (page?.pageHistory.canGoBack == true) {
lastBackButtonPress.value = null;
await activeWebView?.page.value.controller?.goBack();
await page?.controller?.goBack();
return true;
}
@@ -537,10 +569,10 @@ class KagiScreen extends HookConsumerWidget {
const Duration(seconds: 2)) {
lastBackButtonPress.value = null;
if (activeWebView?.key != null && webViews.length > 1) {
ref
.read(webViewRepositoryProvider.notifier)
.closeTab(activeWebView!.tabId);
if (page != null && webViewWidgets.length > 1) {
await ref
.read(tabRepositoryProvider.notifier)
.deleteTab(page.id);
return true;
} else {
//Mark back as unhandled and navigator will pop
@@ -552,7 +584,7 @@ class KagiScreen extends HookConsumerWidget {
..clearSnackBars()
..showSnackBar(
SnackBar(
content: (webViews.length > 1)
content: (webViewWidgets.length > 1)
? const Text(
'Please click BACK again to close current tab',
)
@@ -578,20 +610,16 @@ class KagiScreen extends HookConsumerWidget {
transitionType: SharedAxisTransitionType.horizontal,
child: child,
),
key: ValueKey(
(activeWebView != null)
? webViews.keys
.toList()
.indexOf(activeWebView.tabId)
: null,
),
index: (activeWebView != null)
? webViews.keys
.toList()
.indexOf(activeWebView.tabId) +
1
// key: ValueKey(
// (activeTabId != null)
// ? activeWebViewStackIndex
// : null,
// ),
index: (activeWebViewStackIndex != null &&
activeWebViewStackIndex > -1)
? activeWebViewStackIndex + 1
: 0,
children: [const LandingContent(), ...webViews.values],
children: [const LandingContent(), ...webViewWidgets],
),
),
if (displayedSheet != null)
@@ -1,15 +1,21 @@
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/search_browser/domain/providers.dart';
import 'package:lensai/features/search_browser/presentation/dialogs/tab_action.dart';
import 'package:lensai/features/topics/domain/providers.dart';
import 'package:lensai/features/topics/domain/repositories/tab.dart';
import 'package:lensai/features/topics/presentation/widgets/topic_chips.dart';
import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart';
import 'package:lensai/features/web_view/domain/repositories/web_view.dart';
import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart';
import 'package:lensai/features/web_view/presentation/widgets/web_view_tab.dart';
class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
static const _headerSize = 104.0;
final VoidCallback onClose;
_SliverHeaderDelagate({required this.onClose});
@@ -43,10 +49,12 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
label: const Text('New Tab'),
),
TextButton.icon(
onPressed: () {
ref
.read(webViewRepositoryProvider.notifier)
onPressed: () async {
final topic = ref.read(selectedTopicProvider);
await ref
.read(topicTabRepositoryProvider(topic).notifier)
.closeAllTabs();
onClose();
},
icon: const Icon(Icons.delete),
@@ -55,6 +63,7 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
],
),
TopicChips(),
const SizedBox(height: 8),
],
),
);
@@ -64,10 +73,10 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
}
@override
double get minExtent => 96;
double get minExtent => _headerSize;
@override
double get maxExtent => 96;
double get maxExtent => _headerSize;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
@@ -105,90 +114,135 @@ class ViewTabsSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return CustomScrollView(
return FadingScroll(
fadingSize: 25,
shaderPadding:
const EdgeInsets.only(top: _SliverHeaderDelagate._headerSize),
controller: sheetScrollController,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _SliverHeaderDelagate(onClose: onClose),
),
HookConsumer(
builder: (context, ref, child) {
final tabs = ref.watch(
webViewRepositoryProvider.select((tabs) => tabs.values.toList()),
);
final activeTab = ref.watch(
webViewTabControllerProvider.select(
(webView) => webView?.tabId,
),
);
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(
topicTabRepositoryProvider(topic)
.select((value) => value.valueOrNull ?? []),
);
final activeTab = ref.watch(webViewTabControllerProvider);
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],
);
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 =
tabs.indexWhere((webView) => webView.tabId == activeTab);
useEffect(
() {
final index = availableTabs
.indexWhere((webView) => webView == activeTab);
if (index > -1) {
final reversedIndex = tabs.length - 1 - index;
final offset = (reversedIndex ~/ 2) * itemHeight;
if (index > -1) {
final reversedIndex = availableTabs.length - 1 - index;
final offset = (reversedIndex ~/ 2) * itemHeight;
if (offset != sheetScrollController.offset) {
unawaited(
sheetScrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
if (offset != controller.offset) {
unawaited(
controller.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
return null;
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)
? WebViewTab(
tab: tab,
isActive: tabId == activeTab,
onTap: () {
if (tabId != activeTab) {
//Close first to avoid rebuilds
onClose();
ref
.read(
webViewTabControllerProvider
.notifier,
)
.showTab(tab.id);
} else {
onClose();
}
},
onLongPress: () {
ref
.read(
overlayDialogProvider.notifier,
)
.show(
TabActionDialog(
initialTab: tab,
onDismiss: ref
.read(
overlayDialogProvider
.notifier,
)
.dismiss,
),
);
},
onDelete: () async {
await ref
.read(
tabRepositoryProvider.notifier,
)
.deleteTab(tab.id);
},
)
: const SizedBox.shrink();
},
),
)
.toList(),
),
);
},
[],
);
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: tabs.reversed
.map(
(webView) => HookBuilder(
key: ValueKey(webView.tabId),
builder: (context) {
final tab = useValueListenable(webView.page) as ITab;
return WebViewTab(
tab: tab,
isActive: webView.tabId == activeTab,
onClose: onClose,
);
},
),
)
.toList(),
),
);
},
),
],
),
],
);
},
);
}
}
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/web_view/domain/repositories/web_view.dart';
import 'package:lensai/features/web_view/domain/providers.dart';
class TabsActionButton extends HookConsumerWidget {
final bool isActive;
@@ -17,7 +17,7 @@ class TabsActionButton extends HookConsumerWidget {
final theme = Theme.of(context);
final tabCount =
ref.watch(webViewRepositoryProvider.select((tabs) => tabs.length));
ref.watch(activeTabsProvider.select((tabs) => tabs.length));
return InkWell(
onTap: onTap,