diff --git a/lib/core/api/api_endpoints.dart b/lib/core/api/api_endpoints.dart index 9420f88..67d8387 100644 --- a/lib/core/api/api_endpoints.dart +++ b/lib/core/api/api_endpoints.dart @@ -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/"; diff --git a/lib/features/events/models/event_models.dart b/lib/features/events/models/event_models.dart index 5344365..1f3d333 100644 --- a/lib/features/events/models/event_models.dart +++ b/lib/features/events/models/event_models.dart @@ -68,6 +68,10 @@ class EventModel { // Structured important info list [{title, value}, ...] final List> 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(), ); } } diff --git a/lib/features/reviews/models/review_models.dart b/lib/features/reviews/models/review_models.dart new file mode 100644 index 0000000..d30a262 --- /dev/null +++ b/lib/features/reviews/models/review_models.dart @@ -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 j, {Map? 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 distribution; + + ReviewStatsModel({ + required this.averageRating, + required this.reviewCount, + required this.distribution, + }); + + factory ReviewStatsModel.fromJson(Map j) { + final dist = {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 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, + }); +} diff --git a/lib/features/reviews/services/review_service.dart b/lib/features/reviews/services/review_service.dart new file mode 100644 index 0000000..3c67d82 --- /dev/null +++ b/lib/features/reviews/services/review_service.dart @@ -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 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? ?? {}; + final interactionsMap = >{}; + 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.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.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 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 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 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'); + } + } +} diff --git a/lib/features/reviews/widgets/review_card.dart b/lib/features/reviews/widgets/review_card.dart new file mode 100644 index 0000000..212024d --- /dev/null +++ b/lib/features/reviews/widgets/review_card.dart @@ -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 Function(int reviewId) onHelpful; + final Future Function(int reviewId) onFlag; + + const ReviewCard({ + Key? key, + required this.review, + this.currentUsername, + required this.onHelpful, + required this.onFlag, + }) : super(key: key); + + @override + State createState() => _ReviewCardState(); +} + +class _ReviewCardState extends State { + 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 _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 _handleFlag() async { + if (_isOwnReview || _review.userFlagged) return; + final confirmed = await showDialog( + 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), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/reviews/widgets/review_form.dart b/lib/features/reviews/widgets/review_form.dart new file mode 100644 index 0000000..fb3f562 --- /dev/null +++ b/lib/features/reviews/widgets/review_form.dart @@ -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 Function(int rating, String? comment) onSubmit; + + const ReviewForm({ + Key? key, + required this.eventId, + this.existingReview, + required this.onSubmit, + }) : super(key: key); + + @override + State createState() => _ReviewFormState(); +} + +enum _FormState { idle, loading, success } + +class _ReviewFormState extends State { + 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 _checkAuth() async { + final token = await TokenStorage.getToken(); + final username = await TokenStorage.getUsername(); + if (mounted) setState(() => _isLoggedIn = token != null && username != null); + } + + Future _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), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/reviews/widgets/review_section.dart b/lib/features/reviews/widgets/review_section.dart new file mode 100644 index 0000000..8e962c1 --- /dev/null +++ b/lib/features/reviews/widgets/review_section.dart @@ -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 createState() => _ReviewSectionState(); +} + +class _ReviewSectionState extends State { + final ReviewService _service = ReviewService(); + + List _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 _init() async { + _currentUsername = await TokenStorage.getUsername(); + await _loadReviews(); + } + + Future _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 _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 _handleSubmit(int rating, String? comment) async { + await _service.submitReview(widget.eventId, rating, comment); + await _loadReviews(); // Refresh to get updated stats + review list + } + + Future _handleHelpful(int reviewId) async { + return _service.markHelpful(reviewId); + } + + Future _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), + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/reviews/widgets/review_summary.dart b/lib/features/reviews/widgets/review_summary.dart new file mode 100644 index 0000000..f0231c4 --- /dev/null +++ b/lib/features/reviews/widgets/review_summary.dart @@ -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(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(0xFFFBBF24)), + minHeight: 8, + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 24, + child: Text('$count', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + ), + ], + ), + ); + }), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/reviews/widgets/star_display.dart b/lib/features/reviews/widgets/star_display.dart new file mode 100644 index 0000000..ac6d509 --- /dev/null +++ b/lib/features/reviews/widgets/star_display.dart @@ -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); + }), + ); + } +} diff --git a/lib/features/reviews/widgets/star_rating_input.dart b/lib/features/reviews/widgets/star_rating_input.dart new file mode 100644 index 0000000..4d87542 --- /dev/null +++ b/lib/features/reviews/widgets/star_rating_input.dart @@ -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 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, + ), + ), + ], + ], + ); + } +} diff --git a/lib/screens/learn_more_screen.dart b/lib/screens/learn_more_screen.dart index 51f7e77..e3d945e 100644 --- a/lib/screens/learn_more_screen.dart +++ b/lib/screens/learn_more_screen.dart @@ -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 { 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 { 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), ], ),