Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
176 changes: 176 additions & 0 deletions lib/features/rate/screens/rate_counterpart_screen.dart
Original file line number Diff line number Diff line change
@@ -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<RateCounterpartScreen> createState() =>
_RateCounterpartScreenState();
}

class _RateCounterpartScreenState
extends ConsumerState<RateCounterpartScreen> {
int _rating = 0;
bool _isSubmitting = false;

Future<void> _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();
Comment on lines +36 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

SUBMIT currently reports success without saving anything.

This handler only waits 300 ms and pops the route; it never calls rust/src/api/reputation.rs::submit_rating(). The UI therefore shows a successful flow while nothing is persisted, and backend errors like PrivacyModeEnabled / AlreadyRated can never reach the user. Wire the generated bridge call here and dismiss only after it succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/rate/screens/rate_counterpart_screen.dart` around lines 36 - 43,
In _submit(), replace the placeholder delay with a call to the generated Rust
bridge function reputation.submit_rating(widget.orderId, _rating) (or the
equivalent FFI wrapper) and await it; only call context.pop() after that call
completes successfully; catch bridge errors and surface them to the user (e.g.,
show a snackbar/dialog) so privacy/AlreadyRated errors are visible; ensure
_isSubmitting is cleared in a finally block (setState(() => _isSubmitting =
false)) and keep the mounted checks before calling context.pop() or any UI
updates.

} 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<AppColors>();
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),
],
),
),
),
);
}
}
56 changes: 56 additions & 0 deletions lib/features/rate/widgets/star_rating.dart
Original file line number Diff line number Diff line change
@@ -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<int>? onChanged;

/// Diameter of each star icon in logical pixels.
final double starSize;

@override
Widget build(BuildContext context) {
final colors = Theme.of(context).extension<AppColors>();
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,
),
);
}),
);
}
}
62 changes: 61 additions & 1 deletion lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +107,13 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
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.';
}

Expand Down Expand Up @@ -643,6 +656,53 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
),
),
],

// ── 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'),
),
],
],
),
);
Expand Down
1 change: 1 addition & 0 deletions rust/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading