Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -54,6 +54,10 @@ class AutoSuggestTextField extends HookWidget {
final bool autocorrect;
final GlobalKey? textFieldKey;
/// Whether pressing the keyboard submit/enter button should automatically
/// accept and complete an inline search suggestion. Defaults to false.
final bool acceptSuggestionOnSubmit;
const AutoSuggestTextField({
super.key,
required this.controller,
@@ -84,6 +88,7 @@ class AutoSuggestTextField extends HookWidget {
this.onSuggestionDismiss,
this.autocorrect = false,
this.textFieldKey,
this.acceptSuggestionOnSubmit = false,
});
bool _suggestionHasMatch() =>
@@ -215,7 +220,7 @@ class AutoSuggestTextField extends HookWidget {
autocorrect: autocorrect,
onFieldSubmitted: onSubmitted.mapNotNull(
(onSubmitted) => (value) {
if (_suggestionHasMatch()) {
if (acceptSuggestionOnSubmit && _suggestionHasMatch()) {
onSubmitted(suggestion!);
} else {
onSubmitted(value);
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class InlineCountBadge extends StatelessWidget {
const InlineCountBadge({
required this.count,
required this.backgroundColor,
required this.foregroundColor,
super.key,
});
final int count;
final Color backgroundColor;
final Color foregroundColor;
static String formatCount(int count) {
if (count >= 1000) {
final k = count / 1000;
return k == k.roundToDouble()
? '${k.round()}k'
: '${k.toStringAsFixed(1)}k';
}
return count.toString();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(10),
),
child: Text(
formatCount(count),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: foregroundColor,
),
),
);
}
}
@@ -54,6 +54,36 @@ class _GestureWrapper extends StatelessWidget {
}
}
/// Per-item visual / delete-button overrides for [SelectableChips].
///
/// Grouped into a single value so the [SelectableChips] constructor
/// doesn't grow another knob every time a new variant adds a per-item
/// override. Pass via [SelectableChips.decoration]; any callback left
/// null falls back to the [FilterChip] default for that property.
///
/// All callbacks receive the item and (where applicable) its current
/// `isSelected` flag so the decoration can branch on selection state.
class SelectableChipDecoration<T> {
final Color? Function(T item, bool isSelected)? color;
final BorderSide? Function(T item, bool isSelected)? side;
final Widget? Function(T item)? deleteIcon;
final EdgeInsetsGeometry? Function(T item)? labelPadding;
/// Per-item override for the global [SelectableChips.enableDelete] toggle.
/// When [SelectableChips.enableDelete] is false, no item shows a delete
/// button regardless of this callback. When it's true, this callback
/// can hide the delete button for individual items (returning false).
final bool Function(T item)? canDelete;
const SelectableChipDecoration({
this.color,
this.side,
this.deleteIcon,
this.labelPadding,
this.canDelete,
});
}
class SelectableChips<T extends S, S, K> extends StatelessWidget {
final Iterable<T> availableItems;
final List<Widget> prefixListItems;
@@ -71,9 +101,8 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
final Widget? Function(T item)? itemAvatar;
final String? Function(T item)? itemTooltip;
final int? Function(T item)? itemBadgeCount;
final Color? Function(T item)? itemBackgroundColor;
final Color? selectedBorderColor;
final EdgeInsetsGeometry? Function(T item)? labelPadding;
final SelectableChipDecoration<T>? decoration;
final Widget Function(Widget child, S item)? itemWrap;
@@ -88,8 +117,8 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
this.itemBadgeCount,
this.itemWrap,
this.itemTooltip,
this.itemBackgroundColor,
this.selectedBorderColor,
this.decoration,
this.prefixListItems = const [],
required this.availableItems,
this.selectedItem,
@@ -102,7 +131,6 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
this.scrollController,
this.activeItemKey,
this.cacheExtent = 0,
this.labelPadding,
super.key,
});
@@ -137,7 +165,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
itemBuilder: (context, index) {
if (index < prefixListItems.length) {
return Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
padding: const EdgeInsets.only(top: 4.0, right: 4),
child: prefixListItems[index],
);
}
@@ -146,6 +174,10 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
final isSelected =
selectedItem != null &&
itemId(item) == itemId(selectedItem as S);
final deco = decoration;
final itemColorValue = deco?.color?.call(item, isSelected);
final canDeleteItem =
enableDelete && (deco?.canDelete?.call(item) ?? true);
final child = Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: _BadgeWrapper(
@@ -156,9 +188,13 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
() => callback(item),
),
child: FilterChip(
color: itemColorValue != null
? WidgetStatePropertyAll(itemColorValue)
: null,
selected: selectedBorderColor == null && isSelected,
showCheckmark: false,
labelPadding: labelPadding?.call(item),
labelPadding: deco?.labelPadding?.call(item),
deleteIcon: deco?.deleteIcon?.call(item),
onSelected: (value) {
if (value) {
onSelected?.call(item);
@@ -166,7 +202,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
onDeleted?.call(item);
}
},
onDeleted: enableDelete
onDeleted: canDeleteItem
? () {
onDeleted?.call(item);
}
@@ -174,10 +210,14 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
label: itemLabel.call(item),
avatar: itemAvatar?.call(item),
tooltip: itemTooltip?.call(item),
backgroundColor: itemBackgroundColor?.call(item),
side: isSelected && selectedBorderColor != null
? BorderSide(color: selectedBorderColor!, width: 2.0)
: null,
side:
deco?.side?.call(item, isSelected) ??
(isSelected && selectedBorderColor != null
? BorderSide(
color: selectedBorderColor!,
width: 2.0,
)
: null),
),
),
),
@@ -17,32 +17,73 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:typed_data';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
Uint8List? selectFirstCachedIconBytes(Iterable<Uint8List?> cachedBytesByUrl) {
for (final cachedBytes in cachedBytesByUrl) {
if (cachedBytes != null) {
return cachedBytes;
}
}
return null;
}
class UrlIcon extends HookConsumerWidget {
final double iconSize;
final List<Uri> urlList;
final bool cacheOnly;
const UrlIcon(this.urlList, {required this.iconSize, super.key});
const UrlIcon(
this.urlList, {
required this.iconSize,
this.cacheOnly = false,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final eligibleUrls = urlList
.where((u) => u.isScheme('http') || u.isScheme('https'))
.toList();
final cachedBytesByUrl = [
for (final url in eligibleUrls)
ref.watch(watchCachedIconBytesProvider(url.origin)).value,
];
final cacheHashes = [
for (final bytes in cachedBytesByUrl)
bytes == null ? null : identityHashCode(bytes),
];
final icon = useCachedFuture(
() =>
// ignore: discarded_futures
ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList),
[EquatableValue(urlList)],
() => _resolveIcon(ref, eligibleUrls, cachedBytesByUrl),
[EquatableValue(urlList), ...cacheHashes, cacheOnly],
);
return Skeletonizer(
enabled: icon.connectionState != ConnectionState.done,
// Only show the skeleton on the *initial* resolve (no data yet).
// When a fresh favicon arrives later, `useMemoized` produces a new
// future and `useFuture` flips connectionState back to waiting while
// preserving the previous data — without this guard, the existing
// icon would get a skeleton overlay for the brief async gap before
// BrowserIcon.fromBytes resolves, which reads as a flicker.
enabled:
icon.connectionState != ConnectionState.done && icon.data == null,
child: SizedBox.square(
dimension: iconSize,
child: (icon.data != null)
@@ -59,4 +100,45 @@ class UrlIcon extends HookConsumerWidget {
),
);
}
Future<BrowserIcon?> _resolveIcon(
WidgetRef ref,
List<Uri> eligibleUrls,
List<Uint8List?> cachedBytesByUrl,
) async {
if (eligibleUrls.isEmpty) {
return null;
}
final cachedBytes = selectFirstCachedIconBytes(cachedBytesByUrl);
if (cachedBytes != null) {
final decoded = await BrowserIcon.fromBytes(
cachedBytes,
dominantColor: null,
source: IconSource.disk,
);
if (decoded != null) {
if (!cacheOnly) {
_scheduleStaleRefresh(ref, eligibleUrls);
}
return decoded;
}
}
if (cacheOnly) {
return ref
.read(genericWebsiteServiceProvider.notifier)
.getUrlIcon(urlList, cacheOnly: true);
}
return ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList);
}
void _scheduleStaleRefresh(WidgetRef ref, List<Uri> eligibleUrls) {
if (eligibleUrls.isEmpty) return;
final service = ref.read(genericWebsiteServiceProvider.notifier);
for (final url in eligibleUrls) {
unawaited(service.refreshIconIfStale(url));
}
}
}
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -26,7 +27,7 @@ class UrlListTile extends StatelessWidget {
final Uri uri;
final Widget? leading;
final Widget? trailing;
final Color? borderColor;
final Color? containerColor;
final bool showHttpScheme;
final VoidCallback? onTap;
@@ -36,7 +37,7 @@ class UrlListTile extends StatelessWidget {
required this.uri,
this.leading,
this.trailing,
this.borderColor,
this.containerColor,
this.showHttpScheme = true,
this.onTap,
});
@@ -48,13 +49,17 @@ class UrlListTile extends StatelessWidget {
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final containerPalette = containerColor != null
? ContainerColors.palette(context, containerColor!)
: null;
return Container(
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
decoration: BoxDecoration(
color: containerPalette?.surfaceColor,
borderRadius: _borderRadius,
border: borderColor != null
? Border(right: BorderSide(color: borderColor!, width: 4.0))
border: containerPalette != null
? Border.all(color: containerPalette.outlineColor)
: null,
),
child: Material(
@@ -23,10 +23,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/presentation/controllers/website_title.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class WebsiteTitleTile extends HookConsumerWidget {
final TabState initialTabState;
@@ -35,6 +37,27 @@ class WebsiteTitleTile extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sandboxSourceUri = ref.watch(
sandboxSourceUriForTabProvider(tabId: initialTabState.id),
);
// Sandbox-captured tabs: render the canonical source URL with the
// cached favicon (no network fetch) and skip the page-info fetch entirely.
// We must not hit the source URL behind the user's back, and the loopback
// URL wouldn't yield anything useful.
if (sandboxSourceUri != null) {
return ListTile(
leading: UrlIcon([sandboxSourceUri], iconSize: 24, cacheOnly: true),
contentPadding: EdgeInsets.zero,
title: Text(
initialTabState.title.whenNotEmpty ?? sandboxSourceUri.authority,
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
subtitle: UriBreadcrumb(uri: sandboxSourceUri),
);
}
final pageInfoAsync = ref.watch(completePageInfoProvider(initialTabState));
return Skeletonizer(