Add Supa account and search changes
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user