fix(ux): filter clear pinned, no-match text, instant avatar save, market avatar, favourites, WoT drawer

Six reported UX issues:
- Inventory 'clear filters' is now pinned outside the horizontal scroll,
  always visible — you no longer have to scroll past every chip to find it.
- Empty result now reads 'No seeds match your filters' (not 'no seeds
  yet') when a SEARCH or the 'this month' filter hid everything — the
  'filtered' flag was missing query + sowThisMonthOnly. Guarded by a test.
- Profile avatar saves + publishes the instant you pick it, so it no
  longer looks saved when it wasn't (the Save button sits below the fold).
- Market offer detail shows the author's real avatar (photo/illustration)
  instead of a generic person icon — resolves + caches their kind:0 picture.
- 'Wishlist' renamed to Favourites/Favoritos (en/es/pt/ast).
- The drawer 'following' item now opens the web-of-trust 'your people'
  screen when the social layer is on (was a dead item).

analyze clean; inventory/market/home + overflow suites green.
This commit is contained in:
vjrj 2026-07-12 02:56:08 +02:00
parent 514d3c7364
commit be5977d688
14 changed files with 91 additions and 33 deletions

View file

@ -74,7 +74,7 @@
"market": "Mercáu",
"profile": "El to perfil",
"chat": "Charra",
"wishlist": "Llista de deseos",
"wishlist": "Favoritos",
"following": "Siguiendo",
"plantares": "Plantares",
"sales": "Ventes",

View file

@ -75,7 +75,7 @@
"market": "Market",
"profile": "Your profile",
"chat": "Chat",
"wishlist": "Wishlist",
"wishlist": "Favorites",
"following": "Following",
"plantares": "Plantares",
"sales": "Sales",

View file

@ -75,7 +75,7 @@
"market": "Mercado",
"profile": "Tu perfil",
"chat": "Chat",
"wishlist": "Lista de deseos",
"wishlist": "Favoritos",
"following": "Siguiendo",
"plantares": "Plantares",
"sales": "Ventas",

View file

@ -75,7 +75,7 @@
"market": "Mercado",
"profile": "O teu perfil",
"chat": "Conversas",
"wishlist": "Lista de desejos",
"wishlist": "Favoritos",
"following": "A seguir",
"plantares": "Plantares",
"sales": "Vendas",

View file

@ -6,7 +6,7 @@
/// Locales: 4
/// Strings: 2032 (508 per locale)
///
/// Built on 2026-07-11 at 20:16 UTC
/// Built on 2026-07-12 at 00:47 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -223,7 +223,7 @@ class _Translations$menu$ast extends Translations$menu$en {
@override String get market => 'Mercáu';
@override String get profile => 'El to perfil';
@override String get chat => 'Charra';
@override String get wishlist => 'Llista de deseos';
@override String get wishlist => 'Favoritos';
@override String get following => 'Siguiendo';
@override String get plantares => 'Plantares';
@override String get sales => 'Ventes';
@ -1366,7 +1366,7 @@ extension on TranslationsAst {
'menu.market' => 'Mercáu',
'menu.profile' => 'El to perfil',
'menu.chat' => 'Charra',
'menu.wishlist' => 'Llista de deseos',
'menu.wishlist' => 'Favoritos',
'menu.following' => 'Siguiendo',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Ventes',

View file

@ -344,8 +344,8 @@ class Translations$menu$en {
/// en: 'Chat'
String get chat => 'Chat';
/// en: 'Wishlist'
String get wishlist => 'Wishlist';
/// en: 'Favorites'
String get wishlist => 'Favorites';
/// en: 'Following'
String get following => 'Following';
@ -2370,7 +2370,7 @@ extension on Translations {
'menu.market' => 'Market',
'menu.profile' => 'Your profile',
'menu.chat' => 'Chat',
'menu.wishlist' => 'Wishlist',
'menu.wishlist' => 'Favorites',
'menu.following' => 'Following',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Sales',

View file

@ -224,7 +224,7 @@ class _Translations$menu$es extends Translations$menu$en {
@override String get market => 'Mercado';
@override String get profile => 'Tu perfil';
@override String get chat => 'Chat';
@override String get wishlist => 'Lista de deseos';
@override String get wishlist => 'Favoritos';
@override String get following => 'Siguiendo';
@override String get plantares => 'Plantares';
@override String get sales => 'Ventas';
@ -1369,7 +1369,7 @@ extension on TranslationsEs {
'menu.market' => 'Mercado',
'menu.profile' => 'Tu perfil',
'menu.chat' => 'Chat',
'menu.wishlist' => 'Lista de deseos',
'menu.wishlist' => 'Favoritos',
'menu.following' => 'Siguiendo',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Ventas',

View file

@ -224,7 +224,7 @@ class _Translations$menu$pt extends Translations$menu$en {
@override String get market => 'Mercado';
@override String get profile => 'O teu perfil';
@override String get chat => 'Conversas';
@override String get wishlist => 'Lista de desejos';
@override String get wishlist => 'Favoritos';
@override String get following => 'A seguir';
@override String get plantares => 'Plantares';
@override String get sales => 'Vendas';
@ -1366,7 +1366,7 @@ extension on TranslationsPt {
'menu.market' => 'Mercado',
'menu.profile' => 'O teu perfil',
'menu.chat' => 'Conversas',
'menu.wishlist' => 'Lista de desejos',
'menu.wishlist' => 'Favoritos',
'menu.following' => 'A seguir',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Vendas',

View file

@ -104,9 +104,17 @@ class AppDrawer extends StatelessWidget {
icon: const Icon(Icons.favorite),
label: t.menu.wishlist,
),
// The ego-centric web of trust ("your people") live once the
// social layer is on.
_DrawerItem(
icon: const Icon(Icons.group),
label: t.menu.following,
onTap: marketEnabled
? () {
Navigator.of(context).pop();
context.push('/your-people');
}
: null,
),
],
),

View file

@ -85,13 +85,16 @@ class InventoryListScreen extends StatelessWidget {
items: state.visibleItems,
selectionMode: state.selectionMode,
selectedIds: state.selectedIds,
// Distinguish "no seeds at all" from "filters hid them".
// Distinguish "no seeds at all" from "search/filters
// hid them" — so an empty result reads "no matches".
filtered:
state.query.trim().isNotEmpty ||
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly,
state.sharingOnly ||
state.sowThisMonthOnly,
),
),
],
@ -412,22 +415,30 @@ class _FilterBar extends StatelessWidget {
));
}
}
if (hasActiveFilter) {
row.add(TextButton.icon(
key: const Key('inventory.filter.clear'),
onPressed: cubit.clearFilters,
icon: const Icon(Icons.clear, size: 18),
label: Text(t.inventory.clearFilters),
));
}
return EdgeFade(
final scroller = EdgeFade(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: row),
),
);
if (!hasActiveFilter) return scroller;
// Pin "clear filters" outside the scroll so it's always visible — you
// shouldn't have to scroll past every chip to discover it exists.
return Row(
children: [
Expanded(child: scroller),
const _FilterGroupDivider(),
TextButton.icon(
key: const Key('inventory.filter.clear'),
onPressed: cubit.clearFilters,
icon: const Icon(Icons.filter_alt_off_outlined, size: 18),
label: Text(t.inventory.clearFilters),
),
const SizedBox(width: 4),
],
);
}
}

View file

@ -11,6 +11,7 @@ import '../services/social_connection.dart';
import '../services/social_service.dart';
import '../state/peer_rating_cubit.dart';
import 'market_widgets.dart';
import 'peer_avatar.dart';
import 'rating_sheet.dart';
import 'theme.dart';
@ -53,6 +54,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
String? _sellerName;
String? _sellerAbout;
String? _sellerG1;
String? _sellerPicture;
bool _profileLoading = true;
PeerRatingState? _sellerRating;
@ -66,7 +68,14 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
/// same path the chat header uses. Degrades silently when offline.
Future<void> _loadSeller() async {
final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex);
if (mounted && cachedName != null) setState(() => _sellerName = cachedName);
final cachedPic =
await widget.profileCache?.picture(widget.offer.authorPubkeyHex);
if (mounted) {
setState(() {
if (cachedName != null) _sellerName = cachedName;
if (cachedPic != null) _sellerPicture = cachedPic;
});
}
final session = await widget.connection.session();
if (!mounted) return;
@ -81,11 +90,18 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
unawaited(widget.profileCache
?.setName(widget.offer.authorPubkeyHex, profile.name));
}
if (profile != null && profile.picture.isNotEmpty) {
unawaited(widget.profileCache
?.setPicture(widget.offer.authorPubkeyHex, profile.picture));
}
if (mounted) {
setState(() {
if (profile != null && profile.name.isNotEmpty) {
_sellerName = profile.name;
}
if (profile != null && profile.picture.isNotEmpty) {
_sellerPicture = profile.picture;
}
_sellerAbout = profile?.about.isNotEmpty == true ? profile!.about : null;
_sellerG1 = profile?.g1.isNotEmpty == true ? profile!.g1 : null;
_profileLoading = false;
@ -194,7 +210,9 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
const SizedBox(height: 12),
_SellerSection(
title: t.market.sharedBy,
pubkey: o.authorPubkeyHex,
name: _sellerName ?? shortPubkey(o.authorPubkeyHex),
picture: _sellerPicture,
about: _sellerAbout,
rating: _sellerRating,
loading: _profileLoading,
@ -250,6 +268,7 @@ class _Badge extends StatelessWidget {
class _SellerSection extends StatelessWidget {
const _SellerSection({
required this.title,
required this.pubkey,
required this.name,
required this.about,
required this.loading,
@ -257,11 +276,14 @@ class _SellerSection extends StatelessWidget {
required this.noProfileLabel,
required this.onCopyId,
required this.copyIdTooltip,
this.picture,
this.rating,
});
final String title;
final String pubkey;
final String name;
final String? picture;
final String? about;
/// Circle-weighted summary of the author's ratings; stars show only when
@ -289,11 +311,7 @@ class _SellerSection extends StatelessWidget {
const SizedBox(height: 8),
Row(
children: [
const CircleAvatar(
radius: 20,
backgroundColor: seedPrimaryContainer,
child: Icon(Icons.person_outline, color: seedGreen),
),
PeerAvatar(pubkey: pubkey, name: name, picture: picture, radius: 20),
const SizedBox(width: 12),
Expanded(
child: Column(

View file

@ -114,7 +114,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
Future<void> _editAvatar() async {
final result = await showAvatarPicker(context, current: _avatar);
if (result != null && mounted) setState(() => _avatar = result);
if (result == null || !mounted) return;
setState(() => _avatar = result);
// Persist + publish right away: an avatar change shouldn't depend on
// finding the Save button further down the screen.
await _save();
}
Future<void> _copyId() async {

View file

@ -35,6 +35,23 @@ void main() {
await disposeTree(tester);
});
testWidgets('a search with no matches reads "no matches", not "no seeds yet"',
(tester) async {
final repo = newTestRepository(db);
await repo.addQuickVariety(label: 'Tomate', category: 'Solanaceae');
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('inventory.search')), 'zzzz');
await tester.pumpAndSettle();
expect(find.text('No seeds match your filters.'), findsOneWidget);
expect(find.text('No seeds yet. Tap + to add your first.'), findsNothing);
await disposeTree(tester);
});
testWidgets(
'renders seeds from the repository grouped under their category',
(tester) async {