Files
Eventify-frontend/lib/features/reviews/widgets/review_card.dart
Sicherhaven 1badeff966 feat: add complete review/rating system for events
New feature: Users can view, submit, and interact with event reviews.

Components added:
- ReviewModel, ReviewStatsModel, ReviewListResponse (models)
- ReviewService with getReviews, submitReview, markHelpful, flagReview
- StarRatingInput (interactive 5-star picker with labels)
- StarDisplay (read-only fractional star display)
- ReviewSummary (average rating + distribution bars)
- ReviewForm (star picker + comment field + submit/update)
- ReviewCard (avatar, timestamp, expandable comment, helpful/flag)
- ReviewSection (main container with pagination and state mgmt)

Integration:
- Added to LearnMoreScreen (both mobile and desktop layouts)
- Review API endpoints point to app.eventifyplus.com Node.js backend
- EventModel updated with averageRating/reviewCount fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:04:37 +05:30

225 lines
7.9 KiB
Dart

// lib/features/reviews/widgets/review_card.dart
import 'package:flutter/material.dart';
import '../models/review_models.dart';
import 'star_display.dart';
class ReviewCard extends StatefulWidget {
final ReviewModel review;
final String? currentUsername;
final Future<int> Function(int reviewId) onHelpful;
final Future<void> Function(int reviewId) onFlag;
const ReviewCard({
Key? key,
required this.review,
this.currentUsername,
required this.onHelpful,
required this.onFlag,
}) : super(key: key);
@override
State<ReviewCard> createState() => _ReviewCardState();
}
class _ReviewCardState extends State<ReviewCard> {
late ReviewModel _review;
bool _expanded = false;
@override
void initState() {
super.initState();
_review = widget.review;
}
@override
void didUpdateWidget(covariant ReviewCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.review.id != widget.review.id) _review = widget.review;
}
bool get _isOwnReview =>
widget.currentUsername != null &&
widget.currentUsername!.isNotEmpty &&
_review.username == widget.currentUsername;
String _timeAgo(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 30) return '${diff.inDays}d ago';
if (diff.inDays < 365) return '${(diff.inDays / 30).floor()}mo ago';
return '${(diff.inDays / 365).floor()}y ago';
}
Color _avatarColor(String name) {
final colors = [
const Color(0xFF0F45CF), const Color(0xFF7C3AED), const Color(0xFFEC4899),
const Color(0xFFF59E0B), const Color(0xFF10B981), const Color(0xFFEF4444),
const Color(0xFF06B6D4), const Color(0xFF8B5CF6),
];
return colors[name.hashCode.abs() % colors.length];
}
Future<void> _handleHelpful() async {
if (_isOwnReview) return;
try {
final newCount = await widget.onHelpful(_review.id);
if (mounted) {
setState(() {
_review = _review.copyWith(
helpfulCount: newCount,
userMarkedHelpful: !_review.userMarkedHelpful,
);
});
}
} catch (_) {}
}
Future<void> _handleFlag() async {
if (_isOwnReview || _review.userFlagged) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Report Review'),
content: const Text('Are you sure you want to report this review as inappropriate?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Report', style: TextStyle(color: Color(0xFFEF4444))),
),
],
),
);
if (confirmed != true) return;
try {
await widget.onFlag(_review.id);
if (mounted) setState(() => _review = _review.copyWith(userFlagged: true));
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final comment = _review.comment ?? '';
final isLong = comment.length > 150;
final displayComment = isLong && !_expanded ? '${comment.substring(0, 150)}...' : comment;
final initial = _review.username.isNotEmpty ? _review.username[0].toUpperCase() : '?';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFF1F5F9)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: _avatarColor(_review.username),
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
_review.username.split('@').first,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Color(0xFF1E293B)),
overflow: TextOverflow.ellipsis,
),
),
if (_review.isVerified) ...[
const SizedBox(width: 4),
const Icon(Icons.verified, size: 14, color: Color(0xFF22C55E)),
],
],
),
const SizedBox(height: 2),
Text(_timeAgo(_review.createdAt), style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
],
),
),
StarDisplay(rating: _review.rating.toDouble(), size: 14),
],
),
// Comment
if (comment.isNotEmpty) ...[
const SizedBox(height: 10),
Text(displayComment, style: const TextStyle(fontSize: 13, color: Color(0xFF334155), height: 1.4)),
if (isLong)
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_expanded ? 'Show less' : 'Read more',
style: const TextStyle(fontSize: 12, color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
),
),
),
],
// Footer actions
const SizedBox(height: 10),
Row(
children: [
// Helpful button
InkWell(
onTap: _isOwnReview ? null : _handleHelpful,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_review.userMarkedHelpful ? Icons.thumb_up : Icons.thumb_up_outlined,
size: 15,
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
),
if (_review.helpfulCount > 0) ...[
const SizedBox(width: 4),
Text(
'${_review.helpfulCount}',
style: TextStyle(
fontSize: 12,
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
),
),
],
],
),
),
),
const SizedBox(width: 8),
// Flag button
if (!_isOwnReview)
InkWell(
onTap: _review.userFlagged ? null : _handleFlag,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Icon(
_review.userFlagged ? Icons.flag : Icons.flag_outlined,
size: 15,
color: _review.userFlagged ? const Color(0xFFEF4444) : const Color(0xFF94A3B8),
),
),
),
],
),
],
),
);
}
}