diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 5e7f767..1df43c4 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -172,6 +172,7 @@ class TaneApp extends StatelessWidget { connection: connection, mine: offer.authorPubkeyHex == social.publicKeyHex, profileCache: profileCache, + selfPubkey: social.publicKeyHex, ); }, ), diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 7b6f353..3fb855c 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -550,6 +550,14 @@ "revokeConfirm": "¿Dexar d'avalar a esta persona?", "offline": "Ensin conexón — vuelvi tentalo cuando teas en llinia" }, + "ratings": { + "rate": "Valorar a esta persona", + "edit": "Editar la to valoración", + "commentHint": "¿Qué tal foi? (opcional)", + "fromYourCircle": "{n} de xente que conoces", + "retract": "Quitar la to valoración", + "saved": "Valoración guardada" + }, "notifications": { "newMessageFrom": "Mensaxe nuevu de {name}" }, diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 14ea365..ccd2dac 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -553,6 +553,14 @@ "revokeConfirm": "Stop vouching for this person?", "offline": "You're offline — try again when you're connected" }, + "ratings": { + "rate": "Rate this person", + "edit": "Edit your rating", + "commentHint": "How did it go? (optional)", + "fromYourCircle": "{n} from people you know", + "retract": "Remove your rating", + "saved": "Rating saved" + }, "notifications": { "newMessageFrom": "New message from {name}" }, diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 5f9a231..7834d97 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -552,6 +552,14 @@ "revokeConfirm": "¿Dejar de avalar a esta persona?", "offline": "Sin conexión — inténtalo cuando estés en línea" }, + "ratings": { + "rate": "Valorar a esta persona", + "edit": "Editar tu valoración", + "commentHint": "¿Qué tal fue? (opcional)", + "fromYourCircle": "{n} de gente que conoces", + "retract": "Quitar tu valoración", + "saved": "Valoración guardada" + }, "notifications": { "newMessageFrom": "Nuevo mensaje de {name}" }, diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 8134f39..68a0611 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -549,6 +549,14 @@ "revokeConfirm": "Deixar de avalizar esta pessoa?", "offline": "Sem ligação — tenta novamente quando estiveres em linha" }, + "ratings": { + "rate": "Avaliar esta pessoa", + "edit": "Editar a tua avaliação", + "commentHint": "Como correu? (opcional)", + "fromYourCircle": "{n} de pessoas que conheces", + "retract": "Remover a tua avaliação", + "saved": "Avaliação guardada" + }, "notifications": { "newMessageFrom": "Nova mensagem de {name}" }, diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 5d0192c..f10ea03 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1816 (454 per locale) +/// Strings: 1840 (460 per locale) /// -/// Built on 2026-07-11 at 10:58 UTC +/// Built on 2026-07-11 at 11:11 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index ac8b2e5..c5fd943 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -77,6 +77,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Ensin conexón — vuelvi tentalo cuando teas en llinia'; } +// Path: ratings +class _Translations$ratings$ast extends Translations$ratings$en { + _Translations$ratings$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get rate => 'Valorar a esta persona'; + @override String get edit => 'Editar la to valoración'; + @override String get commentHint => '¿Qué tal foi? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de xente que conoces'; + @override String get retract => 'Quitar la to valoración'; + @override String get saved => 'Valoración guardada'; +} + // Path: notifications class _Translations$notifications$ast extends Translations$notifications$en { _Translations$notifications$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1627,6 +1643,12 @@ extension on TranslationsAst { 'yourPeople.revoke' => 'Dexar d\'avalar', 'yourPeople.revokeConfirm' => '¿Dexar d\'avalar a esta persona?', 'yourPeople.offline' => 'Ensin conexón — vuelvi tentalo cuando teas en llinia', + 'ratings.rate' => 'Valorar a esta persona', + 'ratings.edit' => 'Editar la to valoración', + 'ratings.commentHint' => '¿Qué tal foi? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de xente que conoces', + 'ratings.retract' => 'Quitar la to valoración', + 'ratings.saved' => 'Valoración guardada', 'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}', 'plantare.title' => 'Plantares', 'plantare.help' => 'Un Plantare ye\'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index e3c6bff..cb034e5 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -78,6 +78,7 @@ class Translations with BaseTranslations { late final Translations$chat$en chat = Translations$chat$en.internal(_root); late final Translations$trust$en trust = Translations$trust$en.internal(_root); late final Translations$yourPeople$en yourPeople = Translations$yourPeople$en.internal(_root); + late final Translations$ratings$en ratings = Translations$ratings$en.internal(_root); late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root); late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root); late final Translations$sale$en sale = Translations$sale$en.internal(_root); @@ -1505,6 +1506,33 @@ class Translations$yourPeople$en { String get offline => 'You\'re offline — try again when you\'re connected'; } +// Path: ratings +class Translations$ratings$en { + Translations$ratings$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Rate this person' + String get rate => 'Rate this person'; + + /// en: 'Edit your rating' + String get edit => 'Edit your rating'; + + /// en: 'How did it go? (optional)' + String get commentHint => 'How did it go? (optional)'; + + /// en: '{n} from people you know' + String fromYourCircle({required Object n}) => '${n} from people you know'; + + /// en: 'Remove your rating' + String get retract => 'Remove your rating'; + + /// en: 'Rating saved' + String get saved => 'Rating saved'; +} + // Path: notifications class Translations$notifications$en { Translations$notifications$en.internal(this._root); @@ -2525,6 +2553,12 @@ extension on Translations { 'yourPeople.revoke' => 'Stop vouching', 'yourPeople.revokeConfirm' => 'Stop vouching for this person?', 'yourPeople.offline' => 'You\'re offline — try again when you\'re connected', + 'ratings.rate' => 'Rate this person', + 'ratings.edit' => 'Edit your rating', + 'ratings.commentHint' => 'How did it go? (optional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} from people you know', + 'ratings.retract' => 'Remove your rating', + 'ratings.saved' => 'Rating saved', 'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}', 'plantare.title' => 'Plantares', 'plantare.help' => 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It\'s not a sale.', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 67c1fab..d142ede 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -77,6 +77,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Sin conexión — inténtalo cuando estés en línea'; } +// Path: ratings +class _Translations$ratings$es extends Translations$ratings$en { + _Translations$ratings$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get rate => 'Valorar a esta persona'; + @override String get edit => 'Editar tu valoración'; + @override String get commentHint => '¿Qué tal fue? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de gente que conoces'; + @override String get retract => 'Quitar tu valoración'; + @override String get saved => 'Valoración guardada'; +} + // Path: notifications class _Translations$notifications$es extends Translations$notifications$en { _Translations$notifications$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1631,6 +1647,12 @@ extension on TranslationsEs { 'yourPeople.revoke' => 'Dejar de avalar', 'yourPeople.revokeConfirm' => '¿Dejar de avalar a esta persona?', 'yourPeople.offline' => 'Sin conexión — inténtalo cuando estés en línea', + 'ratings.rate' => 'Valorar a esta persona', + 'ratings.edit' => 'Editar tu valoración', + 'ratings.commentHint' => '¿Qué tal fue? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de gente que conoces', + 'ratings.retract' => 'Quitar tu valoración', + 'ratings.saved' => 'Valoración guardada', 'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}', 'plantare.title' => 'Plantares', 'plantare.help' => 'Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 26f46c8..612b68f 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -77,6 +77,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Sem ligação — tenta novamente quando estiveres em linha'; } +// Path: ratings +class _Translations$ratings$pt extends Translations$ratings$en { + _Translations$ratings$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get rate => 'Avaliar esta pessoa'; + @override String get edit => 'Editar a tua avaliação'; + @override String get commentHint => 'Como correu? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de pessoas que conheces'; + @override String get retract => 'Remover a tua avaliação'; + @override String get saved => 'Avaliação guardada'; +} + // Path: notifications class _Translations$notifications$pt extends Translations$notifications$en { _Translations$notifications$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1625,6 +1641,12 @@ extension on TranslationsPt { 'yourPeople.revoke' => 'Deixar de avalizar', 'yourPeople.revokeConfirm' => 'Deixar de avalizar esta pessoa?', 'yourPeople.offline' => 'Sem ligação — tenta novamente quando estiveres em linha', + 'ratings.rate' => 'Avaliar esta pessoa', + 'ratings.edit' => 'Editar a tua avaliação', + 'ratings.commentHint' => 'Como correu? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de pessoas que conheces', + 'ratings.retract' => 'Remover a tua avaliação', + 'ratings.saved' => 'Avaliação guardada', 'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}', 'plantare.title' => 'Plantares', 'plantare.help' => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.', diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart index 17d02dd..2c8b863 100644 --- a/apps/app_seeds/lib/services/social_service.dart +++ b/apps/app_seeds/lib/services/social_service.dart @@ -89,13 +89,14 @@ class SocialService { } } -/// One relay channel with the three transports on top — the -/// "one channel, three interfaces" shape, at the app layer. +/// One relay channel with the transports on top — the +/// "one channel, N interfaces" shape, at the app layer. class SocialSession { SocialSession(this._channel, {String deviceId = ''}) : offers = NostrOfferTransport(_channel), messages = NostrMessageTransport(_channel), trust = NostrTrustTransport(_channel), + ratings = NostrRatingTransport(_channel), profile = NostrProfileTransport(_channel), sync = NostrSyncTransport( _channel, @@ -108,6 +109,7 @@ class SocialSession { final OfferTransport offers; final MessageTransport messages; final TrustTransport trust; + final RatingTransport ratings; final ProfileTransport profile; /// Device-to-device inventory sync for THIS identity's own devices. diff --git a/apps/app_seeds/lib/state/peer_rating_cubit.dart b/apps/app_seeds/lib/state/peer_rating_cubit.dart new file mode 100644 index 0000000..d59f80c --- /dev/null +++ b/apps/app_seeds/lib/state/peer_rating_cubit.dart @@ -0,0 +1,145 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Reputation of one peer, seen from this user's position: everyone's active +/// ratings plus how many of those come from people in your own circle — the +/// signal that makes strangers' review-stuffing irrelevant. +class PeerRatingState extends Equatable { + const PeerRatingState({ + this.count = 0, + this.average = 0, + this.circleCount = 0, + this.myStars, + this.myComment = '', + this.loading = true, + this.busy = false, + }); + + /// Active ratings of the peer, one per rater. + final int count; + + /// Mean stars over [count]; 0 when there are none. + final double average; + + /// How many of those ratings come from your circle (you or a + /// friend-of-a-friend). These are the ones worth highlighting. + final int circleCount; + + /// Your own rating of the peer, if any. + final int? myStars; + final String myComment; + + final bool loading; + final bool busy; + + bool get hasRatings => count > 0; + bool get iRated => myStars != null; + + PeerRatingState copyWith({ + int? count, + double? average, + int? circleCount, + int? Function()? myStars, + String? myComment, + bool? loading, + bool? busy, + }) => + PeerRatingState( + count: count ?? this.count, + average: average ?? this.average, + circleCount: circleCount ?? this.circleCount, + myStars: myStars != null ? myStars() : this.myStars, + myComment: myComment ?? this.myComment, + loading: loading ?? this.loading, + busy: busy ?? this.busy, + ); + + @override + List get props => + [count, average, circleCount, myStars, myComment, loading, busy]; +} + +/// Reads and edits this user's rating of [peerPubkey] over a +/// [RatingTransport], and summarises everyone else's. The circle weighting +/// reuses the trust graph (same ego-centric rule as TrustCubit). +class PeerRatingCubit extends Cubit { + PeerRatingCubit( + this._ratings, { + required this.peerPubkey, + required this.selfPubkey, + TrustTransport? trust, + }) : _trust = trust, + super(const PeerRatingState()); + + final RatingTransport? _ratings; + final TrustTransport? _trust; + final String peerPubkey; + final String selfPubkey; + + bool get isOnline => _ratings != null; + + /// Same personal "circle" rule as TrustCubit: one vouch from your side, out + /// to a friend-of-a-friend. + static const _circleThreshold = 1; + static const _circleDistance = 2; + + Future load() async { + final ratings = _ratings; + if (ratings == null) { + emit(state.copyWith(loading: false)); + return; + } + emit(state.copyWith(loading: true)); + final all = await ratings.ratingsOf(peerPubkey); + final mine = await ratings.myRatingOf(peerPubkey); + + var circle = const {}; + final trust = _trust; + if (trust != null && all.isNotEmpty) { + final wot = WebOfTrust.fromCertifications( + await trust.allCertifications(), + now: DateTime.now(), + ); + circle = wot.members( + seeds: {selfPubkey}, + threshold: _circleThreshold, + maxDistance: _circleDistance, + ); + } + + final total = all.fold(0, (sum, r) => sum + r.stars); + emit(state.copyWith( + count: all.length, + average: all.isEmpty ? 0 : total / all.length, + circleCount: all.where((r) => circle.contains(r.rater)).length, + myStars: () => mine?.stars, + myComment: mine?.comment ?? '', + loading: false, + )); + } + + /// Publishes (or replaces) your rating, then reloads. Never rates self. + Future rate(int stars, {String comment = ''}) async { + final ratings = _ratings; + if (ratings == null || peerPubkey == selfPubkey) return; + emit(state.copyWith(busy: true)); + await ratings.rate( + subjectPubkey: peerPubkey, + stars: stars, + comment: comment, + ); + await load(); + emit(state.copyWith(busy: false)); + } + + /// Takes your rating back, then reloads. + Future retract() async { + final ratings = _ratings; + if (ratings == null) return; + emit(state.copyWith(busy: true)); + await ratings.retract(subjectPubkey: peerPubkey); + await load(); + emit(state.copyWith(busy: false)); + } +} diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 24e0087..177b007 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -15,8 +15,10 @@ import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/unread_service.dart'; import '../state/messages_cubit.dart'; +import '../state/peer_rating_cubit.dart'; import '../state/trust_cubit.dart'; import 'peer_avatar.dart'; +import 'rating_sheet.dart'; import 'theme.dart'; /// A 1:1 encrypted chat with one peer (redesign screen 10). Private and @@ -52,6 +54,7 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { MessagesCubit? _messages; TrustCubit? _trust; + PeerRatingCubit? _rating; bool _loading = true; String? _peerName; String? _peerG1; @@ -94,9 +97,17 @@ class _ChatScreenState extends State { selfPubkey: self, ); unawaited(trust.load()); + final rating = PeerRatingCubit( + session?.ratings, + peerPubkey: widget.peerPubkey, + selfPubkey: self, + trust: session?.trust, + ); + unawaited(rating.load()); setState(() { _messages = messages; _trust = trust; + _rating = rating; _peerName = cachedName; _loading = false; }); @@ -166,6 +177,7 @@ class _ChatScreenState extends State { unawaited(_unread?.markRead(widget.peerPubkey)); _messages?.close(); _trust?.close(); + _rating?.close(); // The session belongs to the shared connection — don't close it here. _input.dispose(); super.dispose(); @@ -175,6 +187,7 @@ class _ChatScreenState extends State { Widget build(BuildContext context) { final messages = _messages; final trust = _trust; + final rating = _rating; final t = context.t; return Scaffold( appBar: AppBar( @@ -189,12 +202,13 @@ class _ChatScreenState extends State { ), ], ), - body: _loading || messages == null || trust == null + body: _loading || messages == null || trust == null || rating == null ? const Center(child: CircularProgressIndicator()) : MultiBlocProvider( providers: [ BlocProvider.value(value: messages), BlocProvider.value(value: trust), + BlocProvider.value(value: rating), ], child: _ChatBody( controller: _input, @@ -244,6 +258,7 @@ class _ChatBody extends StatelessWidget { child: Column( children: [ const _TrustBanner(), + const _RatingStrip(), Expanded(child: ChatMessageList(peerName: peerName)), _Composer(controller: controller, onSend: onSend), ], @@ -353,6 +368,56 @@ class _TrustBanner extends StatelessWidget { } } +/// A slim reputation strip under the trust banner: everyone's stars for this +/// peer (highlighting how many come from people you know) and, once you've +/// actually talked with them, a button to leave or edit your own rating. +class _RatingStrip extends StatelessWidget { + const _RatingStrip(); + + @override + Widget build(BuildContext context) { + final t = context.t; + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); + // Soft anchor: you can only rate someone you've talked with. + final canRate = context.select( + (m) => m.state.messages.isNotEmpty, + ); + if (!state.hasRatings && !canRate) return const SizedBox.shrink(); + return Container( + width: double.infinity, + color: seedPrimaryContainer.withValues(alpha: 0.3), + padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 4), + child: Row( + children: [ + Expanded( + child: state.hasRatings + ? RatingStars( + average: state.average, + count: state.count, + circleCount: state.circleCount, + ) + : const SizedBox.shrink(), + ), + if (canRate) + TextButton( + key: const Key('chat.rate'), + onPressed: state.busy + ? null + : () => showRatingSheet(context, cubit), + child: + Text(state.iRated ? t.ratings.edit : t.ratings.rate), + ), + ], + ), + ); + }, + ); + } +} + /// A centered pill introducing a new day ("Today", "Yesterday", or a /// locale-formatted date). Localized via [chatDayLabel]. class _DaySeparator extends StatelessWidget { diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart index 430c68f..1ccc1dd 100644 --- a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -8,7 +8,10 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/profile_cache.dart'; import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import '../state/peer_rating_cubit.dart'; import 'market_widgets.dart'; +import 'rating_sheet.dart'; import 'theme.dart'; /// Read-only "product page" for one discovered [Offer] (the Wallapop-style @@ -22,6 +25,7 @@ class MarketOfferDetailScreen extends StatefulWidget { required this.connection, this.mine = false, this.profileCache, + this.selfPubkey, super.key, }); @@ -36,6 +40,10 @@ class MarketOfferDetailScreen extends StatefulWidget { /// Optional cache of peers' published display names; null in tests. final ProfileCache? profileCache; + /// This user's key (hex), to weigh the author's ratings by your own circle; + /// null in tests (circle weighting then stays off). + final String? selfPubkey; + @override State createState() => _MarketOfferDetailScreenState(); @@ -46,6 +54,7 @@ class _MarketOfferDetailScreenState extends State { String? _sellerAbout; String? _sellerG1; bool _profileLoading = true; + PeerRatingState? _sellerRating; @override void initState() { @@ -65,6 +74,7 @@ class _MarketOfferDetailScreenState extends State { setState(() => _profileLoading = false); return; } + unawaited(_loadSellerRating(session)); try { final profile = await session.profile.fetch(widget.offer.authorPubkeyHex); if (profile != null && profile.name.isNotEmpty) { @@ -86,6 +96,25 @@ class _MarketOfferDetailScreenState extends State { } } + /// Best-effort summary of the author's ratings, circle-weighted when we know + /// who this user is. Reuses the cubit's aggregation without providing it. + Future _loadSellerRating(SocialSession session) async { + final cubit = PeerRatingCubit( + session.ratings, + peerPubkey: widget.offer.authorPubkeyHex, + selfPubkey: widget.selfPubkey ?? '', + trust: widget.selfPubkey == null ? null : session.trust, + ); + try { + await cubit.load(); + if (mounted) setState(() => _sellerRating = cubit.state); + } catch (_) { + // offline / unreachable — the seller section simply shows no stars. + } finally { + await cubit.close(); + } + } + // No dispose of the session — it belongs to the shared connection. Future _copyId() async { @@ -167,6 +196,7 @@ class _MarketOfferDetailScreenState extends State { title: t.market.sharedBy, name: _sellerName ?? shortPubkey(o.authorPubkeyHex), about: _sellerAbout, + rating: _sellerRating, loading: _profileLoading, hasProfile: _sellerName != null || _sellerAbout != null || _sellerG1 != null, @@ -227,11 +257,16 @@ class _SellerSection extends StatelessWidget { required this.noProfileLabel, required this.onCopyId, required this.copyIdTooltip, + this.rating, }); final String title; final String name; final String? about; + + /// Circle-weighted summary of the author's ratings; stars show only when + /// someone actually rated them (absence is never a reproach). + final PeerRatingState? rating; final bool loading; final bool hasProfile; final String noProfileLabel; @@ -261,13 +296,26 @@ class _SellerSection extends StatelessWidget { ), const SizedBox(width: 12), Expanded( - child: Text( - name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: seedOnSurface, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + if (rating != null && rating!.hasRatings) ...[ + const SizedBox(height: 2), + RatingStars( + average: rating!.average, + count: rating!.count, + circleCount: rating!.circleCount, + ), + ], + ], ), ), IconButton( diff --git a/apps/app_seeds/lib/ui/rating_sheet.dart b/apps/app_seeds/lib/ui/rating_sheet.dart new file mode 100644 index 0000000..4c10e48 --- /dev/null +++ b/apps/app_seeds/lib/ui/rating_sheet.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../i18n/strings.g.dart'; +import '../state/peer_rating_cubit.dart'; +import 'theme.dart'; + +/// A compact "★ 4,5 (12)" pill, plus how many ratings come from people you +/// know — the signal that makes strangers' reviews easy to weigh. +class RatingStars extends StatelessWidget { + const RatingStars({ + required this.average, + required this.count, + this.circleCount = 0, + super.key, + }); + + final double average; + final int count; + final int circleCount; + + @override + Widget build(BuildContext context) { + final t = context.t; + final locale = Localizations.localeOf(context).toString(); + final avg = NumberFormat('0.#', locale).format(average); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + children: [ + const Icon(Icons.star_rounded, size: 18, color: seedGreen), + Text( + '$avg ($count)', + style: const TextStyle( + color: seedOnSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (circleCount > 0) + Text( + t.ratings.fromYourCircle(n: circleCount), + style: const TextStyle(color: seedGreen, fontSize: 13), + ), + ], + ); + } +} + +/// Opens the bottom sheet to leave, edit or take back your rating of the +/// peer behind [cubit]. Pre-filled when you already rated. +Future showRatingSheet(BuildContext context, PeerRatingCubit cubit) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: _RatingForm(cubit: cubit), + ), + ); +} + +class _RatingForm extends StatefulWidget { + const _RatingForm({required this.cubit}); + + final PeerRatingCubit cubit; + + @override + State<_RatingForm> createState() => _RatingFormState(); +} + +class _RatingFormState extends State<_RatingForm> { + late int _stars; + late final TextEditingController _comment; + bool _saving = false; + + @override + void initState() { + super.initState(); + final state = widget.cubit.state; + _stars = state.myStars ?? 0; + _comment = TextEditingController(text: state.myComment); + } + + @override + void dispose() { + _comment.dispose(); + super.dispose(); + } + + Future _save() async { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final saved = context.t.ratings.saved; + setState(() => _saving = true); + await widget.cubit.rate(_stars, comment: _comment.text.trim()); + navigator.pop(); + messenger.showSnackBar(SnackBar(content: Text(saved))); + } + + Future _retract() async { + final navigator = Navigator.of(context); + setState(() => _saving = true); + await widget.cubit.retract(); + navigator.pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final iRated = widget.cubit.state.iRated; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + iRated ? t.ratings.edit : t.ratings.rate, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: seedTitle, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var s = 1; s <= 5; s++) + IconButton( + key: Key('rating.star.$s'), + iconSize: 36, + onPressed: + _saving ? null : () => setState(() => _stars = s), + icon: Icon( + s <= _stars + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: s <= _stars ? seedGreen : seedMuted, + ), + ), + ], + ), + const SizedBox(height: 8), + TextField( + key: const Key('rating.comment'), + controller: _comment, + enabled: !_saving, + maxLength: 280, + maxLines: 2, + decoration: InputDecoration( + hintText: t.ratings.commentHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + if (iRated) + TextButton( + key: const Key('rating.retract'), + onPressed: _saving ? null : _retract, + child: Text(t.ratings.retract), + ), + const Spacer(), + FilledButton( + key: const Key('rating.save'), + onPressed: _saving || _stars == 0 ? null : _save, + child: Text(t.common.save), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/test/state/peer_rating_cubit_test.dart b/apps/app_seeds/test/state/peer_rating_cubit_test.dart new file mode 100644 index 0000000..e12279f --- /dev/null +++ b/apps/app_seeds/test/state/peer_rating_cubit_test.dart @@ -0,0 +1,162 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/state/peer_rating_cubit.dart'; + +/// In-memory [RatingTransport]: rate/retract act as this device's identity, +/// one live rating per (rater, subject) like the Nostr transport. +class FakeRatingTransport implements RatingTransport { + FakeRatingTransport(this.selfId); + final String selfId; + final Map> _bySubject = {}; // subject -> rater + + /// Test helper: record an arbitrary rater→subject rating. + void addRating(String rater, String subject, int stars, + {String comment = ''}) => + (_bySubject[subject] ??= {})[rater] = Rating( + rater: rater, + subject: subject, + stars: stars, + ratedAt: DateTime(2026), + comment: comment, + ); + + @override + Future rate({ + required String subjectPubkey, + required int stars, + String comment = '', + }) async => + addRating(selfId, subjectPubkey, stars, comment: comment); + + @override + Future retract({required String subjectPubkey}) async => + _bySubject[subjectPubkey]?.remove(selfId); + + @override + Future> ratingsOf(String subjectPubkey) async => + List.of(_bySubject[subjectPubkey]?.values ?? const []); + + @override + Future myRatingOf(String subjectPubkey) async => + _bySubject[subjectPubkey]?[selfId]; + + @override + Future close() async {} +} + +/// Minimal trust transport exposing a fixed certification graph, for the +/// circle weighting. +class FakeTrustTransport implements TrustTransport { + final List certs = []; + + void addCert(String issuer, String subject) => certs.add(Certification( + issuer: issuer, + subject: subject, + issuedAt: DateTime(2026), + )); + + @override + Future> allCertifications() async => List.of(certs); + + @override + Future> certifiersOf(String subjectPubkey) async => { + for (final c in certs) + if (c.subject == subjectPubkey) c.issuer, + }; + + @override + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) async {} + + @override + Future revoke({required String subjectPubkey}) async {} + + @override + Future close() async {} +} + +void main() { + const me = 'me'; + const peer = 'peer'; + + test('aggregates count and average over active ratings', () async { + final ratings = FakeRatingTransport(me) + ..addRating('a', peer, 5) + ..addRating('b', peer, 2); + final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.count, 2); + expect(cubit.state.average, closeTo(3.5, 0.001)); + expect(cubit.state.circleCount, 0); // no trust graph wired + expect(cubit.state.iRated, isFalse); + await cubit.close(); + }); + + test('counts ratings coming from your circle', () async { + final ratings = FakeRatingTransport(me) + ..addRating('friend', peer, 5) // you vouch for 'friend' + ..addRating('stranger', peer, 1); + final trust = FakeTrustTransport()..addCert(me, 'friend'); + final cubit = PeerRatingCubit( + ratings, + peerPubkey: peer, + selfPubkey: me, + trust: trust, + ); + await cubit.load(); + expect(cubit.state.count, 2); + expect(cubit.state.circleCount, 1); + await cubit.close(); + }); + + test('rate publishes then reloads; re-rating replaces', () async { + final ratings = FakeRatingTransport(me); + final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + + await cubit.rate(4, comment: 'Nice seeds'); + expect(cubit.state.myStars, 4); + expect(cubit.state.myComment, 'Nice seeds'); + expect(cubit.state.count, 1); + + await cubit.rate(2); + expect(cubit.state.myStars, 2); + expect(cubit.state.count, 1); // replaced, not piled up + await cubit.close(); + }); + + test('retract removes your rating', () async { + final ratings = FakeRatingTransport(me)..addRating(me, peer, 3); + final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.iRated, isTrue); + + await cubit.retract(); + expect(cubit.state.iRated, isFalse); + expect(cubit.state.count, 0); + await cubit.close(); + }); + + test('never rates self', () async { + final ratings = FakeRatingTransport(me); + final cubit = PeerRatingCubit(ratings, peerPubkey: me, selfPubkey: me); + await cubit.load(); + await cubit.rate(5); + expect(cubit.state.iRated, isFalse); + expect(cubit.state.count, 0); + await cubit.close(); + }); + + test('offline (no transport) never throws', () async { + final cubit = PeerRatingCubit(null, peerPubkey: peer, selfPubkey: me); + expect(cubit.isOnline, isFalse); + await cubit.load(); + await cubit.rate(5); + await cubit.retract(); + expect(cubit.state.loading, isFalse); + await cubit.close(); + }); +} diff --git a/apps/app_seeds/test/ui/rating_sheet_test.dart b/apps/app_seeds/test/ui/rating_sheet_test.dart new file mode 100644 index 0000000..4b82a8d --- /dev/null +++ b/apps/app_seeds/test/ui/rating_sheet_test.dart @@ -0,0 +1,124 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/state/peer_rating_cubit.dart'; +import 'package:tane/ui/rating_sheet.dart'; + +/// In-memory [RatingTransport] (one live rating per pair). +class FakeRatingTransport implements RatingTransport { + FakeRatingTransport(this.selfId); + final String selfId; + final Map byRater = {}; + + @override + Future rate({ + required String subjectPubkey, + required int stars, + String comment = '', + }) async => + byRater[selfId] = Rating( + rater: selfId, + subject: subjectPubkey, + stars: stars, + ratedAt: DateTime(2026), + comment: comment, + ); + + @override + Future retract({required String subjectPubkey}) async => + byRater.remove(selfId); + + @override + Future> ratingsOf(String subjectPubkey) async => + List.of(byRater.values); + + @override + Future myRatingOf(String subjectPubkey) async => byRater[selfId]; + + @override + Future close() async {} +} + +void main() { + const me = 'me'; + const peer = 'peer'; + + late FakeRatingTransport transport; + late PeerRatingCubit cubit; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + transport = FakeRatingTransport(me); + cubit = PeerRatingCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + }); + + tearDown(() => cubit.close()); + + Widget host() => TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showRatingSheet(context, cubit), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + testWidgets('picking stars and saving publishes the rating', + (tester) async { + await tester.pumpWidget(host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Rate this person'), findsOneWidget); + // Save is disabled until stars are picked. + expect( + tester.widget(find.byKey(const Key('rating.save'))).enabled, + isFalse, + ); + + await tester.tap(find.byKey(const Key('rating.star.4'))); + await tester.pump(); + await tester.enterText( + find.byKey(const Key('rating.comment')), 'Great swap'); + await tester.tap(find.byKey(const Key('rating.save'))); + await tester.pumpAndSettle(); + + final mine = transport.byRater[me]!; + expect(mine.stars, 4); + expect(mine.comment, 'Great swap'); + expect(find.text('Rating saved'), findsOneWidget); // snackbar + }); + + testWidgets('opens pre-filled when you already rated; retract removes', + (tester) async { + await cubit.rate(3, comment: 'Fine'); + + await tester.pumpWidget(host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit your rating'), findsOneWidget); + expect(find.text('Fine'), findsOneWidget); // comment pre-filled + + await tester.tap(find.byKey(const Key('rating.retract'))); + await tester.pumpAndSettle(); + + expect(transport.byRater, isEmpty); + }); +} diff --git a/apps/app_seeds/test/ui/your_people_screen_test.dart b/apps/app_seeds/test/ui/your_people_screen_test.dart index 00abf07..4973282 100644 --- a/apps/app_seeds/test/ui/your_people_screen_test.dart +++ b/apps/app_seeds/test/ui/your_people_screen_test.dart @@ -61,6 +61,8 @@ class FakeSession implements SocialSession { @override MessageTransport get messages => throw UnimplementedError(); @override + RatingTransport get ratings => throw UnimplementedError(); + @override ProfileTransport get profile => throw UnimplementedError(); @override SyncTransport get sync => throw UnimplementedError();