feat(ratings): Wallapop-style ratings in chat and offer detail

- SocialSession grows a ratings transport (fifth interface on the one
  shared channel).
- PeerRatingCubit: count, locale-aware average, and circleCount — how
  many ratings come from your own circle (reuses the ego-centric trust
  rule), the signal that makes strangers' review-stuffing irrelevant.
- Chat: slim rating strip under the trust banner; you can rate only
  someone you've talked with (soft anchor until the signed bilateral
  exchange form lands). Sheet with 5 stars + optional comment,
  pre-filled for editing, with a take-back action.
- Offer detail: author's stars under their name, with 'N from people
  you know' when your circle rated them; nothing shown when unrated.
- i18n ratings.* (en/es/pt/ast); tests for cubit and sheet.
This commit is contained in:
vjrj 2026-07-11 13:13:43 +02:00
parent acdacf1821
commit 1926e3bd98
18 changed files with 873 additions and 12 deletions

View file

@ -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<Object?> 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<PeerRatingState> {
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<void> 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 <String>{};
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<int>(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<void> 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<void> 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));
}
}