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>
This commit is contained in:
2026-03-30 18:04:37 +05:30
parent a7f3b215e4
commit 1badeff966
11 changed files with 1034 additions and 0 deletions

View File

@@ -28,6 +28,13 @@ class ApiEndpoints {
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
// Reviews (served by Node.js backend via app.eventifyplus.com)
static const String _reviewBase = "https://app.eventifyplus.com/api/reviews";
static const String reviewSubmit = "$_reviewBase/submit";
static const String reviewList = "$_reviewBase/list";
static const String reviewHelpful = "$_reviewBase/helpful";
static const String reviewFlag = "$_reviewBase/flag";
// Gamification / Contributor Module (TechDocs v2)
static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
static const String leaderboard = "$baseUrl/v1/leaderboard/";

View File

@@ -68,6 +68,10 @@ class EventModel {
// Structured important info list [{title, value}, ...]
final List<Map<String, String>> importantInfo;
// Review stats (populated when backend includes them)
final double? averageRating;
final int? reviewCount;
EventModel({
required this.id,
required this.name,
@@ -91,6 +95,8 @@ class EventModel {
this.longitude,
this.locationName,
this.importantInfo = const [],
this.averageRating,
this.reviewCount,
});
/// Safely parse a double from backend (may arrive as String or num)
@@ -148,6 +154,8 @@ class EventModel {
longitude: _parseDouble(j['longitude']),
locationName: j['location_name'] as String?,
importantInfo: _parseImportantInfo(j['important_info']),
averageRating: (j['average_rating'] as num?)?.toDouble(),
reviewCount: (j['review_count'] as num?)?.toInt(),
);
}
}

View File

@@ -0,0 +1,113 @@
// lib/features/reviews/models/review_models.dart
class ReviewModel {
final int id;
final int eventId;
final String username;
final int rating;
final String? comment;
final String status;
final DateTime createdAt;
final DateTime updatedAt;
final bool isVerified;
final int helpfulCount;
final int flagCount;
final bool userMarkedHelpful;
final bool userFlagged;
ReviewModel({
required this.id,
required this.eventId,
required this.username,
required this.rating,
this.comment,
this.status = 'PUBLISHED',
required this.createdAt,
required this.updatedAt,
this.isVerified = false,
this.helpfulCount = 0,
this.flagCount = 0,
this.userMarkedHelpful = false,
this.userFlagged = false,
});
factory ReviewModel.fromJson(Map<String, dynamic> j, {Map<String, bool>? interactions}) {
return ReviewModel(
id: j['id'] as int,
eventId: j['event_id'] as int,
username: (j['username'] ?? j['display_name'] ?? 'Anonymous') as String,
rating: j['rating'] as int,
comment: j['comment'] as String?,
status: (j['status'] ?? 'PUBLISHED') as String,
createdAt: DateTime.tryParse(j['created_at'] ?? '') ?? DateTime.now(),
updatedAt: DateTime.tryParse(j['updated_at'] ?? '') ?? DateTime.now(),
isVerified: j['is_verified'] == true,
helpfulCount: (j['helpful_count'] ?? 0) as int,
flagCount: (j['flag_count'] ?? 0) as int,
userMarkedHelpful: interactions?['helpful'] ?? false,
userFlagged: interactions?['flag'] ?? false,
);
}
ReviewModel copyWith({int? helpfulCount, bool? userMarkedHelpful, bool? userFlagged}) {
return ReviewModel(
id: id, eventId: eventId, username: username, rating: rating,
comment: comment, status: status, createdAt: createdAt, updatedAt: updatedAt,
isVerified: isVerified,
helpfulCount: helpfulCount ?? this.helpfulCount,
flagCount: flagCount,
userMarkedHelpful: userMarkedHelpful ?? this.userMarkedHelpful,
userFlagged: userFlagged ?? this.userFlagged,
);
}
}
class ReviewStatsModel {
final double averageRating;
final int reviewCount;
final Map<int, int> distribution;
ReviewStatsModel({
required this.averageRating,
required this.reviewCount,
required this.distribution,
});
factory ReviewStatsModel.fromJson(Map<String, dynamic> j) {
final dist = <int, int>{1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
final rawDist = j['distribution'];
if (rawDist is Map) {
rawDist.forEach((k, v) {
final key = int.tryParse(k.toString());
if (key != null && key >= 1 && key <= 5) dist[key] = (v as num).toInt();
});
} else if (rawDist is List) {
for (int i = 0; i < rawDist.length && i < 5; i++) {
dist[i + 1] = (rawDist[i] as num).toInt();
}
}
return ReviewStatsModel(
averageRating: (j['average_rating'] as num?)?.toDouble() ?? 0.0,
reviewCount: (j['review_count'] as num?)?.toInt() ?? 0,
distribution: dist,
);
}
}
class ReviewListResponse {
final List<ReviewModel> reviews;
final ReviewStatsModel stats;
final ReviewModel? userReview;
final int total;
final int page;
final int pageSize;
ReviewListResponse({
required this.reviews,
required this.stats,
this.userReview,
required this.total,
required this.page,
required this.pageSize,
});
}

View File

@@ -0,0 +1,104 @@
// lib/features/reviews/services/review_service.dart
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../models/review_models.dart';
class ReviewService {
final ApiClient _api = ApiClient();
/// Fetch paginated reviews + stats for an event.
Future<ReviewListResponse> getReviews(int eventId, {int page = 1, int pageSize = 10}) async {
try {
final res = await _api.post(
ApiEndpoints.reviewList,
body: {'event_id': eventId, 'page': page, 'page_size': pageSize},
requiresAuth: true,
);
// Parse interactions map: { "review_id": { "helpful": bool, "flag": bool } }
final rawInteractions = res['interactions'] as Map<String, dynamic>? ?? {};
final interactionsMap = <int, Map<String, bool>>{};
rawInteractions.forEach((key, value) {
final id = int.tryParse(key);
if (id != null && value is Map) {
interactionsMap[id] = {
'helpful': value['helpful'] == true,
'flag': value['flag'] == true,
};
}
});
// Parse reviews
final rawReviews = res['reviews'] as List? ?? [];
final reviews = rawReviews.map((r) {
final review = Map<String, dynamic>.from(r as Map);
return ReviewModel.fromJson(review, interactions: interactionsMap[review['id']]);
}).toList();
// Parse stats
final stats = ReviewStatsModel.fromJson(res);
// Parse user's own review
ReviewModel? userReview;
if (res['user_review'] != null && res['user_review'] is Map) {
final ur = Map<String, dynamic>.from(res['user_review'] as Map);
userReview = ReviewModel.fromJson(ur, interactions: interactionsMap[ur['id']]);
}
return ReviewListResponse(
reviews: reviews,
stats: stats,
userReview: userReview,
total: (res['total'] as num?)?.toInt() ?? reviews.length,
page: (res['page'] as num?)?.toInt() ?? page,
pageSize: (res['page_size'] as num?)?.toInt() ?? pageSize,
);
} catch (e) {
throw Exception('Failed to load reviews: $e');
}
}
/// Submit or update a review.
Future<void> submitReview(int eventId, int rating, String? comment) async {
try {
await _api.post(
ApiEndpoints.reviewSubmit,
body: {
'event_id': eventId,
'rating': rating,
if (comment != null && comment.trim().isNotEmpty) 'comment': comment.trim(),
},
requiresAuth: true,
);
} catch (e) {
throw Exception('Failed to submit review: $e');
}
}
/// Toggle helpful vote on a review. Returns new helpful count.
Future<int> markHelpful(int reviewId) async {
try {
final res = await _api.post(
ApiEndpoints.reviewHelpful,
body: {'review_id': reviewId},
requiresAuth: true,
);
return (res['helpful_count'] as num?)?.toInt() ?? 0;
} catch (e) {
throw Exception('Failed to mark review as helpful: $e');
}
}
/// Flag a review for moderation.
Future<void> flagReview(int reviewId) async {
try {
await _api.post(
ApiEndpoints.reviewFlag,
body: {'review_id': reviewId},
requiresAuth: true,
);
} catch (e) {
throw Exception('Failed to flag review: $e');
}
}
}

View File

@@ -0,0 +1,224 @@
// 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),
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,179 @@
// lib/features/reviews/widgets/review_form.dart
import 'package:flutter/material.dart';
import '../../../core/storage/token_storage.dart';
import '../models/review_models.dart';
import 'star_rating_input.dart';
class ReviewForm extends StatefulWidget {
final int eventId;
final ReviewModel? existingReview;
final Future<void> Function(int rating, String? comment) onSubmit;
const ReviewForm({
Key? key,
required this.eventId,
this.existingReview,
required this.onSubmit,
}) : super(key: key);
@override
State<ReviewForm> createState() => _ReviewFormState();
}
enum _FormState { idle, loading, success }
class _ReviewFormState extends State<ReviewForm> {
int _rating = 0;
final _commentController = TextEditingController();
_FormState _state = _FormState.idle;
bool _isLoggedIn = false;
String? _error;
@override
void initState() {
super.initState();
_checkAuth();
if (widget.existingReview != null) {
_rating = widget.existingReview!.rating;
_commentController.text = widget.existingReview!.comment ?? '';
}
}
Future<void> _checkAuth() async {
final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername();
if (mounted) setState(() => _isLoggedIn = token != null && username != null);
}
Future<void> _handleSubmit() async {
if (_rating == 0) {
setState(() => _error = 'Please select a rating');
return;
}
setState(() { _state = _FormState.loading; _error = null; });
try {
await widget.onSubmit(_rating, _commentController.text);
if (mounted) {
setState(() => _state = _FormState.success);
Future.delayed(const Duration(seconds: 3), () {
if (mounted) setState(() => _state = _FormState.idle);
});
}
} catch (e) {
if (mounted) setState(() { _state = _FormState.idle; _error = e.toString(); });
}
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_isLoggedIn) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
const Icon(Icons.login, color: Color(0xFF64748B), size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text('Log in to write a review', style: TextStyle(color: Color(0xFF64748B), fontSize: 14)),
),
],
),
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _state == _FormState.success
? Container(
key: const ValueKey('success'),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF0FDF4),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF86EFAC)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.check_circle, color: Color(0xFF10B981), size: 24),
SizedBox(width: 8),
Text('Review submitted!', style: TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w600, fontSize: 15)),
],
),
)
: Container(
key: const ValueKey('form'),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.existingReview != null ? 'Update your review' : 'Write a review',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1E293B)),
),
const SizedBox(height: 12),
Center(child: StarRatingInput(rating: _rating, onRatingChanged: (r) => setState(() { _rating = r; _error = null; }))),
const SizedBox(height: 12),
TextField(
controller: _commentController,
maxLength: 500,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Share your experience (optional)',
hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
filled: true,
fillColor: const Color(0xFFF8FAFC),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE2E8F0))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE2E8F0))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF0F45CF), width: 1.5)),
counterStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11),
),
),
if (_error != null) ...[
const SizedBox(height: 4),
Text(_error!, style: const TextStyle(color: Color(0xFFEF4444), fontSize: 12)),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 46,
child: ElevatedButton(
onPressed: _state == _FormState.loading ? null : _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0F45CF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
disabledBackgroundColor: const Color(0xFF0F45CF).withValues(alpha: 0.5),
),
child: _state == _FormState.loading
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(
widget.existingReview != null ? 'Update Review' : 'Submit Review',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,195 @@
// lib/features/reviews/widgets/review_section.dart
import 'package:flutter/material.dart';
import '../../../core/storage/token_storage.dart';
import '../models/review_models.dart';
import '../services/review_service.dart';
import 'review_summary.dart';
import 'review_form.dart';
import 'review_card.dart';
class ReviewSection extends StatefulWidget {
final int eventId;
const ReviewSection({Key? key, required this.eventId}) : super(key: key);
@override
State<ReviewSection> createState() => _ReviewSectionState();
}
class _ReviewSectionState extends State<ReviewSection> {
final ReviewService _service = ReviewService();
List<ReviewModel> _reviews = [];
ReviewStatsModel? _stats;
ReviewModel? _userReview;
String? _currentUsername;
bool _loading = true;
String? _error;
int _page = 1;
int _total = 0;
bool _loadingMore = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
_currentUsername = await TokenStorage.getUsername();
await _loadReviews();
}
Future<void> _loadReviews() async {
setState(() { _loading = true; _error = null; });
try {
final response = await _service.getReviews(widget.eventId, page: 1);
if (mounted) {
setState(() {
_reviews = response.reviews;
_stats = response.stats;
_userReview = response.userReview;
_total = response.total;
_page = 1;
_loading = false;
});
}
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString(); });
}
}
Future<void> _loadMore() async {
if (_loadingMore || _reviews.length >= _total) return;
setState(() => _loadingMore = true);
try {
final response = await _service.getReviews(widget.eventId, page: _page + 1);
if (mounted) {
setState(() {
_reviews.addAll(response.reviews);
_page = response.page;
_total = response.total;
_loadingMore = false;
});
}
} catch (_) {
if (mounted) setState(() => _loadingMore = false);
}
}
Future<void> _handleSubmit(int rating, String? comment) async {
await _service.submitReview(widget.eventId, rating, comment);
await _loadReviews(); // Refresh to get updated stats + review list
}
Future<int> _handleHelpful(int reviewId) async {
return _service.markHelpful(reviewId);
}
Future<void> _handleFlag(int reviewId) async {
await _service.flagReview(reviewId);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header
const Text(
'Reviews & Ratings',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 16),
if (_loading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(color: Color(0xFF0F45CF)),
),
)
else if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(_error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _loadReviews,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Retry'),
),
],
),
)
else ...[
// Summary card
if (_stats != null && _stats!.reviewCount > 0) ...[
ReviewSummary(stats: _stats!),
const SizedBox(height: 16),
],
// Review form
ReviewForm(
eventId: widget.eventId,
existingReview: _userReview,
onSubmit: _handleSubmit,
),
const SizedBox(height: 16),
// Divider
if (_reviews.isNotEmpty)
const Divider(color: Color(0xFFF1F5F9), thickness: 1),
// Reviews list
if (_reviews.isEmpty && (_stats == null || _stats!.reviewCount == 0))
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'No reviews yet. Be the first to share your experience!',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
textAlign: TextAlign.center,
),
),
)
else ...[
const SizedBox(height: 12),
...List.generate(_reviews.length, (i) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ReviewCard(
review: _reviews[i],
currentUsername: _currentUsername,
onHelpful: _handleHelpful,
onFlag: _handleFlag,
),
)),
],
// Load more
if (_reviews.length < _total)
Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0F45CF))),
)
: TextButton(
onPressed: _loadMore,
child: const Text(
'Show more reviews',
style: TextStyle(color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,98 @@
// lib/features/reviews/widgets/review_summary.dart
import 'package:flutter/material.dart';
import '../models/review_models.dart';
import 'star_display.dart';
class ReviewSummary extends StatelessWidget {
final ReviewStatsModel stats;
const ReviewSummary({Key? key, required this.stats}) : super(key: key);
@override
Widget build(BuildContext context) {
final maxCount = stats.distribution.values.fold<int>(0, (a, b) => a > b ? a : b);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4)),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left: average rating + stars + count
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
stats.averageRating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Color(0xFF1E293B),
height: 1.1,
),
),
const SizedBox(height: 4),
StarDisplay(rating: stats.averageRating, size: 18),
const SizedBox(height: 4),
Text(
'${stats.reviewCount} review${stats.reviewCount == 1 ? '' : 's'}',
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
],
),
const SizedBox(width: 24),
// Right: distribution bars
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final star = 5 - i;
final count = stats.distribution[star] ?? 0;
final fraction = maxCount > 0 ? count / maxCount : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 18,
child: Text('$star', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF64748B))),
),
const Icon(Icons.star_rounded, size: 12, color: Color(0xFFFBBF24)),
const SizedBox(width: 6),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: 8,
child: LinearProgressIndicator(
value: fraction,
backgroundColor: const Color(0xFFF1F5F9),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFBBF24)),
minHeight: 8,
),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 24,
child: Text('$count', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
),
],
),
);
}),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,42 @@
// lib/features/reviews/widgets/star_display.dart
import 'package:flutter/material.dart';
class StarDisplay extends StatelessWidget {
final double rating;
final double size;
final Color filledColor;
final Color emptyColor;
const StarDisplay({
Key? key,
required this.rating,
this.size = 16,
this.filledColor = const Color(0xFFFBBF24),
this.emptyColor = const Color(0xFFD1D5DB),
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final starPos = i + 1;
IconData icon;
Color color;
if (rating >= starPos) {
icon = Icons.star_rounded;
color = filledColor;
} else if (rating >= starPos - 0.5) {
icon = Icons.star_half_rounded;
color = filledColor;
} else {
icon = Icons.star_outline_rounded;
color = emptyColor;
}
return Icon(icon, size: size, color: color);
}),
);
}
}

View File

@@ -0,0 +1,56 @@
// lib/features/reviews/widgets/star_rating_input.dart
import 'package:flutter/material.dart';
class StarRatingInput extends StatelessWidget {
final int rating;
final ValueChanged<int> onRatingChanged;
final double starSize;
const StarRatingInput({
Key? key,
required this.rating,
required this.onRatingChanged,
this.starSize = 36,
}) : super(key: key);
static const _labels = ['', 'Poor', 'Fair', 'Good', 'Very Good', 'Excellent'];
static const _starGold = Color(0xFFFBBF24);
static const _starEmpty = Color(0xFFD1D5DB);
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final starIndex = i + 1;
return GestureDetector(
onTap: () => onRatingChanged(starIndex),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
starIndex <= rating ? Icons.star_rounded : Icons.star_outline_rounded,
size: starSize,
color: starIndex <= rating ? _starGold : _starEmpty,
),
),
);
}),
),
if (rating > 0) ...[
const SizedBox(height: 4),
Text(
_labels[rating],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _starGold,
),
),
],
],
);
}
}

View File

@@ -12,6 +12,7 @@ import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart';
import '../core/auth/auth_guard.dart';
import '../core/constants.dart';
import '../features/reviews/widgets/review_section.dart';
class LearnMoreScreen extends StatefulWidget {
final int eventId;
@@ -416,6 +417,8 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
const SizedBox(height: 24),
ReviewSection(eventId: widget.eventId),
],
),
),
@@ -556,6 +559,11 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: ReviewSection(eventId: widget.eventId),
),
const SizedBox(height: 100),
],
),