tane/apps/app_seeds/lib/ui/rating_sheet.dart
vjrj 7e7340d643 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.
2026-07-11 13:13:43 +02:00

180 lines
5.1 KiB
Dart

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<void> showRatingSheet(BuildContext context, PeerRatingCubit cubit) {
return showModalBottomSheet<void>(
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<void> _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<void> _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),
),
],
),
],
),
);
}
}