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>
225 lines
7.9 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|