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:
@@ -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/";
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
113
lib/features/reviews/models/review_models.dart
Normal file
113
lib/features/reviews/models/review_models.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
104
lib/features/reviews/services/review_service.dart
Normal file
104
lib/features/reviews/services/review_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
224
lib/features/reviews/widgets/review_card.dart
Normal file
224
lib/features/reviews/widgets/review_card.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
lib/features/reviews/widgets/review_form.dart
Normal file
179
lib/features/reviews/widgets/review_form.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
lib/features/reviews/widgets/review_section.dart
Normal file
195
lib/features/reviews/widgets/review_section.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/features/reviews/widgets/review_summary.dart
Normal file
98
lib/features/reviews/widgets/review_summary.dart
Normal 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))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/features/reviews/widgets/star_display.dart
Normal file
42
lib/features/reviews/widgets/star_display.dart
Normal 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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/features/reviews/widgets/star_rating_input.dart
Normal file
56
lib/features/reviews/widgets/star_rating_input.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user