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:
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user