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,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<String, Map<String, Rating>> _bySubject = {}; // subject -> rater
/// Test helper: record an arbitrary ratersubject 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<void> rate({
required String subjectPubkey,
required int stars,
String comment = '',
}) async =>
addRating(selfId, subjectPubkey, stars, comment: comment);
@override
Future<void> retract({required String subjectPubkey}) async =>
_bySubject[subjectPubkey]?.remove(selfId);
@override
Future<List<Rating>> ratingsOf(String subjectPubkey) async =>
List.of(_bySubject[subjectPubkey]?.values ?? const <Rating>[]);
@override
Future<Rating?> myRatingOf(String subjectPubkey) async =>
_bySubject[subjectPubkey]?[selfId];
@override
Future<void> close() async {}
}
/// Minimal trust transport exposing a fixed certification graph, for the
/// circle weighting.
class FakeTrustTransport implements TrustTransport {
final List<Certification> certs = [];
void addCert(String issuer, String subject) => certs.add(Certification(
issuer: issuer,
subject: subject,
issuedAt: DateTime(2026),
));
@override
Future<List<Certification>> allCertifications() async => List.of(certs);
@override
Future<Set<String>> certifiersOf(String subjectPubkey) async => {
for (final c in certs)
if (c.subject == subjectPubkey) c.issuer,
};
@override
Future<void> certify({
required String subjectPubkey,
Duration validity = const Duration(days: 365),
String note = '',
}) async {}
@override
Future<void> revoke({required String subjectPubkey}) async {}
@override
Future<void> 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();
});
}

View file

@ -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<String, Rating> byRater = {};
@override
Future<void> 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<void> retract({required String subjectPubkey}) async =>
byRater.remove(selfId);
@override
Future<List<Rating>> ratingsOf(String subjectPubkey) async =>
List.of(byRater.values);
@override
Future<Rating?> myRatingOf(String subjectPubkey) async => byRater[selfId];
@override
Future<void> 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<FilledButton>(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);
});
}

View file

@ -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();