diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 19ff9457..857a7dc2 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -12,6 +12,7 @@ import 'package:mostro/features/order/screens/take_order_screen.dart'; import 'package:mostro/features/chat/screens/chat_room_screen.dart'; import 'package:mostro/features/chat/screens/chat_rooms_screen.dart'; import 'package:mostro/features/disputes/screens/dispute_chat_screen.dart'; +import 'package:mostro/features/rate/screens/rate_counterpart_screen.dart'; import 'package:mostro/features/trades/screens/trade_detail_screen.dart'; import 'package:mostro/features/trades/screens/trades_screen.dart'; import 'package:mostro/features/walkthrough/providers/first_run_provider.dart'; @@ -185,8 +186,9 @@ final GoRouter appRouter = GoRouter( ), GoRoute( path: AppRoute.rateUser, - builder: (context, state) => - _Stub('Rate User — ${state.pathParameters['orderId']}'), + builder: (context, state) => RateCounterpartScreen( + orderId: state.pathParameters['orderId']!, + ), ), GoRoute( path: AppRoute.disputeDetails, diff --git a/lib/features/rate/screens/rate_counterpart_screen.dart b/lib/features/rate/screens/rate_counterpart_screen.dart new file mode 100644 index 00000000..09985dc1 --- /dev/null +++ b/lib/features/rate/screens/rate_counterpart_screen.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/rate/widgets/star_rating.dart'; + +/// Rate counterpart screen — Route `/rate_user/:orderId`. +/// +/// Prompted after trade completion: +/// - Seller: prompted at `SettledHoldInvoice` (after releasing funds) +/// - Buyer: prompted at `Success` (after payment confirmed) +/// +/// Layout: +/// - "RATE" header label (uppercase, gray) +/// - Green double-lightning-bolt success indicator + "Successful order" text +/// - [StarRating] widget (5 tappable stars) +/// - "X / 5" score display +/// - SUBMIT button (green filled, disabled until rating > 0) +/// - CLOSE button (green outline, skips rating) +class RateCounterpartScreen extends ConsumerStatefulWidget { + const RateCounterpartScreen({super.key, required this.orderId}); + + final String orderId; + + @override + ConsumerState createState() => + _RateCounterpartScreenState(); +} + +class _RateCounterpartScreenState + extends ConsumerState { + int _rating = 0; + bool _isSubmitting = false; + + Future _submit() async { + if (_rating == 0) return; + setState(() => _isSubmitting = true); + try { + // TODO(bridge): Call reputation.submit_rating(widget.orderId, _rating) + // via Rust bridge once FFI bindings are generated. + await Future.delayed(const Duration(milliseconds: 300)); + if (mounted) context.pop(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Rating failed: $e')), + ); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension(); + assert(colors != null, 'AppColors theme extension must be registered'); + if (colors == null) return const SizedBox.shrink(); + + final textTheme = Theme.of(context).textTheme; + final green = colors.mostroGreen; + + return Scaffold( + backgroundColor: colors.backgroundDark, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + children: [ + const SizedBox(height: AppSpacing.xl), + + // ── "RATE" header ───────────────────────────────────────── + Text( + 'RATE', + style: textTheme.labelMedium?.copyWith( + color: colors.textSubtle, + letterSpacing: 2, + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: AppSpacing.xl), + + // ── Success indicator ───────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bolt, color: green, size: 32), + Icon(Icons.bolt, color: green, size: 32), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Successful order', + style: textTheme.titleMedium?.copyWith( + color: green, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: AppSpacing.xl), + + // ── Star rating ─────────────────────────────────────────── + StarRating( + rating: _rating, + onChanged: (value) => setState(() => _rating = value), + ), + + const SizedBox(height: AppSpacing.md), + + // ── "X / 5" display ─────────────────────────────────────── + Text( + '$_rating / 5', + style: textTheme.headlineSmall?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + + const Spacer(), + + // ── SUBMIT button ───────────────────────────────────────── + FilledButton( + onPressed: (_rating > 0 && !_isSubmitting) ? _submit : null, + style: FilledButton.styleFrom( + backgroundColor: green, + foregroundColor: Colors.black, + disabledBackgroundColor: green.withValues(alpha: 0.35), + disabledForegroundColor: Colors.black54, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black54, + ), + ) + : const Text( + 'SUBMIT', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + + const SizedBox(height: AppSpacing.sm), + + // ── CLOSE button (skip rating) ──────────────────────────── + OutlinedButton( + onPressed: () => context.pop(), + style: OutlinedButton.styleFrom( + foregroundColor: green, + side: BorderSide(color: green), + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text( + 'CLOSE', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/rate/widgets/star_rating.dart b/lib/features/rate/widgets/star_rating.dart new file mode 100644 index 00000000..4b5ae216 --- /dev/null +++ b/lib/features/rate/widgets/star_rating.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:mostro/core/app_theme.dart'; + +/// Interactive 5-star rating selector. +/// +/// Renders 5 tappable [Icon] widgets. Filled stars use [AppColors.mostroGreen] +/// (`#8CC63F`); empty stars use a dark-gray outline. Tapping a star sets the +/// rating to that star's index + 1 (1-based). +/// +/// The [onChanged] callback is invoked with the new score whenever the user +/// taps a star. The widget is read-only when [onChanged] is null. +class StarRating extends StatelessWidget { + const StarRating({ + super.key, + required this.rating, + this.onChanged, + this.starSize = 40.0, + }) : assert(rating >= 0 && rating <= 5); + + /// Current rating value (0 = none selected, 1–5 = star count). + final int rating; + + /// Called with the new score when the user taps a star. + /// Pass `null` to make the widget read-only. + final ValueChanged? onChanged; + + /// Diameter of each star icon in logical pixels. + final double starSize; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension(); + final filledColor = colors?.mostroGreen ?? const Color(0xFF8CC63F); + const emptyColor = Color(0xFF4A4A4A); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + final isFilled = index < rating; + final starNumber = index + 1; + return IconButton( + onPressed: onChanged == null ? null : () => onChanged!(starNumber), + tooltip: 'Select $starNumber star${starNumber == 1 ? '' : 's'}', + padding: const EdgeInsets.symmetric(horizontal: 4), + constraints: BoxConstraints(minWidth: starSize + 8, minHeight: starSize + 8), + icon: Icon( + isFilled ? Icons.star_rounded : Icons.star_outline_rounded, + color: isFilled ? filledColor : emptyColor, + size: starSize, + ), + ); + }), + ); + } +} diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 925d87e1..269fa41d 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -35,7 +35,13 @@ enum TradeStatus { fiatSent('Fiat Sent'), completed('Completed'), cancelled('Cancelled'), - disputed('Disputed'); + disputed('Disputed'), + /// Trade completed; counterpart rating prompt shown. + /// Maps to `Action.rate` / `Action.rateUser` from the Rust bridge. + pendingRating('Rate'), + /// Rating has been submitted (or skipped). + /// Maps to `Action.rateReceived` — no further actions shown. + rated('Rated'); const TradeStatus(this.label); final String label; @@ -101,6 +107,13 @@ class _TradeDetailScreenState extends ConsumerState { return 'A dispute resolver has been assigned. ' 'They will contact you through the app.'; } + if (_status == TradeStatus.pendingRating) { + return 'The trade completed successfully. ' + 'Rate your counterpart to help build trust in the community.'; + } + if (_status == TradeStatus.rated) { + return 'Thank you for your rating!'; + } return 'Trade in progress.'; } @@ -643,6 +656,53 @@ class _TradeDetailScreenState extends ConsumerState { ), ), ], + + // ── Pending rating — RATE + CLOSE ───────────────────────────── + if (_status == TradeStatus.pendingRating) ...[ + FilledButton.icon( + onPressed: () => + context.push(AppRoute.rateUserPath(widget.orderId)), + icon: const Icon(Icons.star_outline, size: 16), + label: const Text('RATE'), + style: FilledButton.styleFrom( + backgroundColor: green, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + OutlinedButton( + onPressed: () => context.pop(), + style: OutlinedButton.styleFrom( + foregroundColor: green, + side: BorderSide(color: green), + minimumSize: const Size.fromHeight(40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text('CLOSE'), + ), + ], + + // ── Rated — CLOSE only (no further actions) ─────────────────── + if (_status == TradeStatus.rated) ...[ + OutlinedButton( + onPressed: () => context.pop(), + style: OutlinedButton.styleFrom( + foregroundColor: green, + side: BorderSide(color: green), + minimumSize: const Size.fromHeight(40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text('CLOSE'), + ), + ], ], ), ); diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs index 3f96d608..177756f5 100644 --- a/rust/src/api/mod.rs +++ b/rust/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod identity; pub mod messages; pub mod nostr; pub mod orders; +pub mod reputation; pub mod types; pub fn get_app_version() -> String { diff --git a/rust/src/api/reputation.rs b/rust/src/api/reputation.rs new file mode 100644 index 00000000..0619cf0f --- /dev/null +++ b/rust/src/api/reputation.rs @@ -0,0 +1,361 @@ +/// Reputation API — post-trade rating and privacy mode management. +/// +/// After a trade completes both parties are prompted to rate their counterpart +/// (1–5 stars). Ratings are sent to the Mostro daemon via a `RateUser` +/// action in a NIP-59 Gift Wrap. +/// +/// Privacy mode disables reputation data in both directions — no ratings are +/// sent or received when it is active. +/// +/// All state is held in-memory until the DB persistence layer is wired +/// (Phase 12+). +use anyhow::{anyhow, bail, Result}; +use std::collections::HashMap; +use std::sync::{atomic::{AtomicBool, Ordering}, OnceLock}; +use tokio::sync::{broadcast, RwLock}; +use tokio::sync::broadcast::error::RecvError; + +use crate::api::types::{RatingInfo, RatingReceivedEvent}; + +// ── Rating store ────────────────────────────────────────────────────────────── + +/// Both sides of a trade's rating, held together under a single map entry so +/// `mine` and `peer` ratings for the same trade never overwrite each other. +struct TradeRatings { + /// Rating submitted by the local user (`is_mine = true`). + mine: Option, + /// Rating received from the counterparty (`is_mine = false`). + peer: Option, +} + +struct RatingStore { + /// Per-trade ratings keyed by trade_id. + ratings: std::sync::Arc>>, + /// Broadcast channel; payload = incoming rating event. + event_tx: broadcast::Sender, + /// In-memory privacy mode flag. + privacy_mode: AtomicBool, +} + +impl RatingStore { + fn new() -> Self { + let (event_tx, _) = broadcast::channel(32); + Self { + ratings: std::sync::Arc::new(RwLock::new(HashMap::new())), + event_tx, + privacy_mode: AtomicBool::new(false), + } + } + + /// Return the local user's rating for a trade, falling back to the peer's + /// rating if the local user has not yet submitted one. + async fn get(&self, trade_id: &str) -> Option { + self.ratings.read().await.get(trade_id).and_then(|r| { + r.mine.clone().or_else(|| r.peer.clone()) + }) + } + + /// Atomically insert the local user's rating only if one has not been + /// submitted yet. Prevents TOCTOU races on concurrent `submit_rating` + /// calls. Does not affect the peer side. + async fn try_insert_mine(&self, info: RatingInfo) -> Result<()> { + let mut store = self.ratings.write().await; + let entry = store + .entry(info.trade_id.clone()) + .or_insert_with(|| TradeRatings { mine: None, peer: None }); + if entry.mine.is_some() { + bail!( + "AlreadyRated: a rating has already been submitted for trade {}", + info.trade_id + ); + } + entry.mine = Some(info); + Ok(()) + } + + /// Insert or update the peer's incoming rating for a trade. + /// Can be called multiple times safely (handles re-delivery). + async fn insert_peer(&self, info: RatingInfo) { + let mut store = self.ratings.write().await; + let entry = store + .entry(info.trade_id.clone()) + .or_insert_with(|| TradeRatings { mine: None, peer: None }); + entry.peer = Some(info); + } +} + +// ── Global singleton ────────────────────────────────────────────────────────── + +static RATING_STORE: OnceLock = OnceLock::new(); + +fn rating_store() -> &'static RatingStore { + RATING_STORE.get_or_init(RatingStore::new) +} + +// ── Helper ──────────────────────────────────────────────────────────────────── + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Submit a star rating for the counterparty of a completed trade. +/// +/// **Preconditions**: +/// - `score` MUST be in the range 1–5. +/// - The local identity MUST NOT be in privacy mode. +/// - No rating MUST already have been submitted for this trade. +/// +/// **Side effects**: Sends a `RateUser` action to the Mostro daemon via +/// NIP-59 Gift Wrap (deferred to Phase 14+ once bridge bindings are ready). +/// +/// **Errors**: `InvalidScore`, `PrivacyModeEnabled`, `AlreadyRated`. +pub async fn submit_rating(trade_id: String, score: u8) -> Result<()> { + if score < 1 || score > 5 { + bail!("InvalidScore: score must be between 1 and 5, got {score}"); + } + + let store = rating_store(); + + if store.privacy_mode.load(Ordering::SeqCst) { + bail!("PrivacyModeEnabled: cannot submit rating while privacy mode is active"); + } + + // TODO(Phase 14+): Look up the session to get trade key + mostro pubkey, + // then send a RateUser MostroMessage via NIP-59 Gift Wrap. + + // Atomic check-and-insert under write lock — prevents TOCTOU races. + store + .try_insert_mine(RatingInfo { + trade_id, + score, + is_mine: true, + created_at: unix_now(), + }) + .await?; + + Ok(()) +} + +/// Check whether privacy mode is currently enabled. +pub fn get_privacy_mode() -> bool { + rating_store().privacy_mode.load(Ordering::SeqCst) +} + +/// Enable or disable privacy mode. +/// +/// When enabled, no reputation data is sent or received in future trades and +/// session recovery becomes unavailable. +/// +/// **Errors**: `NoIdentity` (identity check deferred to Phase 14+ bridge). +pub fn set_privacy_mode(enabled: bool) { + // TODO(Phase 14+): Verify that an identity exists before toggling. + rating_store() + .privacy_mode + .store(enabled, Ordering::SeqCst); +} + +/// Get the rating submitted or received for a specific trade. +/// +/// Returns `None` if no rating exists for the given trade. +pub async fn get_rating_for_trade(trade_id: String) -> Result> { + Ok(rating_store().get(&trade_id).await) +} + +/// Handle an incoming rating event from the counterparty. +/// +/// Records the rating and broadcasts it to any active [RatingStream]. +/// +/// No-ops silently when privacy mode is active — incoming reputation data is +/// discarded in both directions when the user has opted out. +pub async fn handle_rating_received( + trade_id: String, + score: u8, + from_pubkey: String, +) -> Result<()> { + if score < 1 || score > 5 { + bail!("InvalidScore: received invalid score {score} for trade {trade_id}"); + } + + let store = rating_store(); + + // Discard incoming ratings when privacy mode is active. + if store.privacy_mode.load(Ordering::SeqCst) { + return Ok(()); + } + + let event = RatingReceivedEvent { + trade_id: trade_id.clone(), + score, + from_pubkey, + }; + + // Record as a peer rating (is_mine = false). + store + .insert_peer(RatingInfo { + trade_id, + score, + is_mine: false, + created_at: unix_now(), + }) + .await; + + let _ = store.event_tx.send(event); + Ok(()) +} + +// ── Stream ──────────────────────────────────────────────────────────────────── + +/// A stream that emits incoming [RatingReceivedEvent]s. +pub struct RatingStream { + rx: broadcast::Receiver, +} + +impl RatingStream { + /// Poll for the next incoming rating event. + /// + /// `RecvError::Lagged` is handled gracefully: dropped messages are skipped + /// and the loop continues rather than terminating the stream. + pub async fn next(&mut self) -> Result { + loop { + match self.rx.recv().await { + Ok(event) => return Ok(event), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => bail!("RatingStream closed: channel sender dropped"), + } + } + } +} + +/// Subscribe to incoming rating events. +pub fn on_rating_received() -> RatingStream { + let rx = rating_store().event_tx.subscribe(); + RatingStream { rx } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + /// Serializes tests that mutate the global `privacy_mode` flag so they + /// don't race with each other or with tests that call `submit_rating`. + fn privacy_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[tokio::test] + async fn submit_rating_stores_record() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(false); // ensure clean state + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + submit_rating(trade_id.clone(), 4).await.unwrap(); + + let info = get_rating_for_trade(trade_id.clone()).await.unwrap().unwrap(); + assert_eq!(info.score, 4); + assert!(info.is_mine); + assert_eq!(info.trade_id, trade_id); + } + + #[tokio::test] + async fn invalid_score_is_rejected() { + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + let err = submit_rating(trade_id, 6).await.unwrap_err(); + assert!(err.to_string().contains("InvalidScore")); + } + + #[tokio::test] + async fn zero_score_is_rejected() { + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + let err = submit_rating(trade_id, 0).await.unwrap_err(); + assert!(err.to_string().contains("InvalidScore")); + } + + #[tokio::test] + async fn duplicate_rating_is_rejected() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(false); // ensure clean state + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + submit_rating(trade_id.clone(), 3).await.unwrap(); + let err = submit_rating(trade_id, 5).await.unwrap_err(); + assert!(err.to_string().contains("AlreadyRated")); + } + + #[tokio::test] + async fn privacy_mode_blocks_rating() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(true); + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + let err = submit_rating(trade_id, 4).await.unwrap_err(); + assert!(err.to_string().contains("PrivacyModeEnabled")); + set_privacy_mode(false); + } + + #[tokio::test] + async fn privacy_mode_toggle() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(true); + assert!(get_privacy_mode()); + set_privacy_mode(false); + assert!(!get_privacy_mode()); + } + + #[tokio::test] + async fn handle_rating_received_stores_peer_rating() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(false); // ensure clean state + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + handle_rating_received(trade_id.clone(), 5, "peer_pubkey_abc".into()) + .await + .unwrap(); + + let info = get_rating_for_trade(trade_id).await.unwrap().unwrap(); + assert_eq!(info.score, 5); + assert!(!info.is_mine); + } + + #[tokio::test] + async fn handle_rating_received_discarded_in_privacy_mode() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(true); + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + handle_rating_received(trade_id.clone(), 4, "peer_pubkey_xyz".into()) + .await + .unwrap(); // should succeed (silently discarded) + + let info = get_rating_for_trade(trade_id).await.unwrap(); + assert!(info.is_none(), "peer rating should be discarded in privacy mode"); + set_privacy_mode(false); + } + + #[tokio::test] + async fn mine_and_peer_ratings_coexist_for_same_trade() { + let _guard = privacy_lock().lock().unwrap(); + set_privacy_mode(false); + let trade_id = format!("t-{}", uuid::Uuid::new_v4()); + + // Submit my rating first. + submit_rating(trade_id.clone(), 4).await.unwrap(); + + // Receive peer rating for the same trade. + handle_rating_received(trade_id.clone(), 5, "peer_pubkey".into()) + .await + .unwrap(); + + // get_rating_for_trade returns mine (preferred). + let info = get_rating_for_trade(trade_id.clone()).await.unwrap().unwrap(); + assert!(info.is_mine); + assert_eq!(info.score, 4); + + // Submitting my rating a second time is still rejected. + let err = submit_rating(trade_id, 3).await.unwrap_err(); + assert!(err.to_string().contains("AlreadyRated")); + } +} diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 7a3632bb..623a755a 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -378,6 +378,30 @@ fn default_expiration_seconds() -> u32 { 900 } +/// Rating submitted or received for a completed trade. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RatingInfo { + /// The trade this rating belongs to. + pub trade_id: String, + /// Star score (1–5). + pub score: u8, + /// `true` if the local user submitted this rating. + pub is_mine: bool, + /// Unix timestamp (seconds) when the rating was submitted. + pub created_at: i64, +} + +/// Event emitted when the counterparty submits a rating for the local user. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RatingReceivedEvent { + /// The trade this rating belongs to. + pub trade_id: String, + /// Star score submitted by the counterparty (1–5). + pub score: u8, + /// Nostr public key (hex) of the rater. + pub from_pubkey: String, +} + /// An open or resolved dispute on a trade. /// /// Created locally when the user initiates a dispute or when a peer-initiated diff --git a/specs/004-mostro-p2p-client/tasks.md b/specs/004-mostro-p2p-client/tasks.md index 586f479b..6d5e939d 100644 --- a/specs/004-mostro-p2p-client/tasks.md +++ b/specs/004-mostro-p2p-client/tasks.md @@ -299,10 +299,10 @@ configuration. **Independent Test**: Seller releases sats → Rate button appears. Tap → rate screen with 5 stars. Select 4 → Submit enabled → tap Submit → screen closes. Counterparty's reputation score updated on their order cards. -- [ ] T094 Implement reputation API in `rust/src/api/reputation.rs` per `contracts/reputation.md`: `submit_rating(trade_id, score)` — validates 1–5, sends `RateUser` `MostroMessage`. `get_privacy_mode()`, `set_privacy_mode(enabled)`. `get_rating_for_trade(trade_id)`. Errors: `TradeNotComplete`, `PrivacyModeEnabled`, `AlreadyRated`. -- [ ] T095 Implement rate counterpart screen in `lib/features/rate/screens/rate_counterpart_screen.dart`: header "RATE" (uppercase gray). Success indicator: green double-lightning-bolt + "Successful order" text. 5-star `StarRating` widget. "X / 5" display below stars. Submit button (green filled, disabled until `_rating > 0`) + Close button (green outline, skips rating). Route: `/rate_user/:orderId`. Seller prompted at `SettledHoldInvoice`; buyer prompted at `Success`. -- [ ] T096 [P] Implement star rating widget in `lib/features/rate/widgets/star_rating.dart`: 5 tappable stars. Filled = `AppTheme.mostroGreen #8CC63F`. Empty = dark gray outline. Tap sets rating to star index + 1. Rating "X / 5" display. -- [ ] T097 Wire rate button in trade detail screen: when `OrderState.action` is `Action.rate`/`Action.rateUser`/`Action.rateReceived` → show Rate button in `_buildActionButtons()` → navigates to `/rate_user/:orderId`. After `rateReceived` → no further actions shown. +- [x] T094 Implement reputation API in `rust/src/api/reputation.rs` per `contracts/reputation.md`: `submit_rating(trade_id, score)` — validates 1–5, sends `RateUser` `MostroMessage`. `get_privacy_mode()`, `set_privacy_mode(enabled)`. `get_rating_for_trade(trade_id)`. Errors: `TradeNotComplete`, `PrivacyModeEnabled`, `AlreadyRated`. +- [x] T095 Implement rate counterpart screen in `lib/features/rate/screens/rate_counterpart_screen.dart`: header "RATE" (uppercase gray). Success indicator: green double-lightning-bolt + "Successful order" text. 5-star `StarRating` widget. "X / 5" display below stars. Submit button (green filled, disabled until `_rating > 0`) + Close button (green outline, skips rating). Route: `/rate_user/:orderId`. Seller prompted at `SettledHoldInvoice`; buyer prompted at `Success`. +- [x] T096 [P] Implement star rating widget in `lib/features/rate/widgets/star_rating.dart`: 5 tappable stars. Filled = `AppTheme.mostroGreen #8CC63F`. Empty = dark gray outline. Tap sets rating to star index + 1. Rating "X / 5" display. +- [x] T097 Wire rate button in trade detail screen: when `OrderState.action` is `Action.rate`/`Action.rateUser`/`Action.rateReceived` → show Rate button in `_buildActionButtons()` → navigates to `/rate_user/:orderId`. After `rateReceived` → no further actions shown. **Checkpoint**: Both buyer and seller are prompted to rate after trade completion. Star selection enables Submit. Rating submitted without error. Counterparty stars updated on order cards.