Files
worker/docs/md/REVIEWS_CODE_EXAMPLES.md
Phuoc Nguyen 65f6f825a6 update md
2025-11-28 15:16:40 +07:00

26 KiB

Reviews API - Code Examples

Table of Contents

  1. Basic Usage
  2. Advanced Scenarios
  3. Error Handling
  4. Custom Widgets
  5. Testing Examples

Basic Usage

Display Reviews in a List

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/presentation/providers/reviews_provider.dart';

class ReviewsListPage extends ConsumerWidget {
  const ReviewsListPage({super.key, required this.productId});

  final String productId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final reviewsAsync = ref.watch(productReviewsProvider(productId));

    return Scaffold(
      appBar: AppBar(title: const Text('Reviews')),
      body: reviewsAsync.when(
        data: (reviews) {
          if (reviews.isEmpty) {
            return const Center(
              child: Text('No reviews yet'),
            );
          }

          return ListView.builder(
            itemCount: reviews.length,
            itemBuilder: (context, index) {
              final review = reviews[index];
              return ListTile(
                title: Text(review.reviewerName ?? 'Anonymous'),
                subtitle: Text(review.comment),
                trailing: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: List.generate(
                    5,
                    (i) => Icon(
                      i < review.starsRating ? Icons.star : Icons.star_border,
                      size: 16,
                      color: Colors.amber,
                    ),
                  ),
                ),
              );
            },
          );
        },
        loading: () => const Center(
          child: CircularProgressIndicator(),
        ),
        error: (error, stack) => Center(
          child: Text('Error: $error'),
        ),
      ),
    );
  }
}

Show Average Rating

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/presentation/providers/reviews_provider.dart';

class ProductRatingWidget extends ConsumerWidget {
  const ProductRatingWidget({super.key, required this.productId});

  final String productId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final avgRatingAsync = ref.watch(productAverageRatingProvider(productId));
    final countAsync = ref.watch(productReviewCountProvider(productId));

    return Row(
      children: [
        // Average rating
        avgRatingAsync.when(
          data: (avgRating) => Text(
            avgRating.toStringAsFixed(1),
            style: const TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
            ),
          ),
          loading: () => const Text('--'),
          error: (_, __) => const Text('0.0'),
        ),

        const SizedBox(width: 8),

        // Stars
        avgRatingAsync.when(
          data: (avgRating) => Row(
            children: List.generate(5, (index) {
              if (index < avgRating.floor()) {
                return const Icon(Icons.star, color: Colors.amber);
              } else if (index < avgRating) {
                return const Icon(Icons.star_half, color: Colors.amber);
              } else {
                return const Icon(Icons.star_border, color: Colors.amber);
              }
            }),
          ),
          loading: () => const SizedBox(),
          error: (_, __) => const SizedBox(),
        ),

        const SizedBox(width: 8),

        // Review count
        countAsync.when(
          data: (count) => Text('($count reviews)'),
          loading: () => const Text(''),
          error: (_, __) => const Text(''),
        ),
      ],
    );
  }
}

Submit a Review

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/presentation/providers/reviews_provider.dart';

class SimpleReviewForm extends ConsumerStatefulWidget {
  const SimpleReviewForm({super.key, required this.productId});

  final String productId;

  @override
  ConsumerState<SimpleReviewForm> createState() => _SimpleReviewFormState();
}

class _SimpleReviewFormState extends ConsumerState<SimpleReviewForm> {
  int _selectedRating = 0;
  final _commentController = TextEditingController();
  bool _isSubmitting = false;

  @override
  void dispose() {
    _commentController.dispose();
    super.dispose();
  }

  Future<void> _submitReview() async {
    if (_selectedRating == 0 || _commentController.text.trim().length < 20) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('Please select rating and write at least 20 characters'),
        ),
      );
      return;
    }

    setState(() => _isSubmitting = true);

    try {
      final submitUseCase = ref.read(submitReviewProvider);

      // Convert stars (1-5) to API rating (0-1)
      final apiRating = _selectedRating / 5.0;

      await submitUseCase(
        itemId: widget.productId,
        rating: apiRating,
        comment: _commentController.text.trim(),
      );

      if (mounted) {
        // Refresh reviews list
        ref.invalidate(productReviewsProvider(widget.productId));

        // Show success
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text('Review submitted successfully!'),
            backgroundColor: Colors.green,
          ),
        );

        // Clear form
        setState(() {
          _selectedRating = 0;
          _commentController.clear();
        });
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Error: $e'),
            backgroundColor: Colors.red,
          ),
        );
      }
    } finally {
      if (mounted) {
        setState(() => _isSubmitting = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Star rating selector
          Row(
            children: List.generate(5, (index) {
              final star = index + 1;
              return IconButton(
                icon: Icon(
                  star <= _selectedRating ? Icons.star : Icons.star_border,
                  color: Colors.amber,
                ),
                onPressed: () => setState(() => _selectedRating = star),
              );
            }),
          ),

          const SizedBox(height: 16),

          // Comment field
          TextField(
            controller: _commentController,
            maxLines: 5,
            maxLength: 1000,
            decoration: const InputDecoration(
              hintText: 'Write your review...',
              border: OutlineInputBorder(),
            ),
          ),

          const SizedBox(height: 16),

          // Submit button
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: _isSubmitting ? null : _submitReview,
              child: _isSubmitting
                  ? const CircularProgressIndicator()
                  : const Text('Submit Review'),
            ),
          ),
        ],
      ),
    );
  }
}

Advanced Scenarios

Paginated Reviews List

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/domain/entities/review.dart';
import 'package:worker/features/reviews/domain/usecases/get_product_reviews.dart';

class PaginatedReviewsList extends ConsumerStatefulWidget {
  const PaginatedReviewsList({super.key, required this.productId});

  final String productId;

  @override
  ConsumerState<PaginatedReviewsList> createState() =>
      _PaginatedReviewsListState();
}

class _PaginatedReviewsListState
    extends ConsumerState<PaginatedReviewsList> {
  final List<Review> _reviews = [];
  int _currentPage = 0;
  final int _pageSize = 10;
  bool _isLoading = false;
  bool _hasMore = true;

  @override
  void initState() {
    super.initState();
    _loadMoreReviews();
  }

  Future<void> _loadMoreReviews() async {
    if (_isLoading || !_hasMore) return;

    setState(() => _isLoading = true);

    try {
      final getReviews = ref.read(getProductReviewsProvider);

      final newReviews = await getReviews(
        itemId: widget.productId,
        limitPageLength: _pageSize,
        limitStart: _currentPage * _pageSize,
      );

      setState(() {
        _reviews.addAll(newReviews);
        _currentPage++;
        _hasMore = newReviews.length == _pageSize;
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error loading reviews: $e')),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: _reviews.length + (_hasMore ? 1 : 0),
      itemBuilder: (context, index) {
        if (index == _reviews.length) {
          // Load more button
          return Padding(
            padding: const EdgeInsets.all(16),
            child: Center(
              child: _isLoading
                  ? const CircularProgressIndicator()
                  : ElevatedButton(
                      onPressed: _loadMoreReviews,
                      child: const Text('Load More'),
                    ),
            ),
          );
        }

        final review = _reviews[index];
        return ListTile(
          title: Text(review.reviewerName ?? 'Anonymous'),
          subtitle: Text(review.comment),
          leading: CircleAvatar(
            child: Text('${review.starsRating}'),
          ),
        );
      },
    );
  }
}

Pull-to-Refresh Reviews

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/presentation/providers/reviews_provider.dart';

class RefreshableReviewsList extends ConsumerWidget {
  const RefreshableReviewsList({super.key, required this.productId});

  final String productId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final reviewsAsync = ref.watch(productReviewsProvider(productId));

    return RefreshIndicator(
      onRefresh: () async {
        // Invalidate provider to trigger refresh
        ref.invalidate(productReviewsProvider(productId));

        // Wait for data to load
        await ref.read(productReviewsProvider(productId).future);
      },
      child: reviewsAsync.when(
        data: (reviews) {
          if (reviews.isEmpty) {
            // Must return a scrollable widget for RefreshIndicator
            return ListView(
              children: const [
                Center(
                  child: Padding(
                    padding: EdgeInsets.all(40),
                    child: Text('No reviews yet'),
                  ),
                ),
              ],
            );
          }

          return ListView.builder(
            itemCount: reviews.length,
            itemBuilder: (context, index) {
              final review = reviews[index];
              return ListTile(
                title: Text(review.reviewerName ?? 'Anonymous'),
                subtitle: Text(review.comment),
              );
            },
          );
        },
        loading: () => ListView(
          children: const [
            Center(
              child: Padding(
                padding: EdgeInsets.all(40),
                child: CircularProgressIndicator(),
              ),
            ),
          ],
        ),
        error: (error, stack) => ListView(
          children: [
            Center(
              child: Padding(
                padding: const EdgeInsets.all(40),
                child: Text('Error: $error'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Filter Reviews by Rating

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/domain/entities/review.dart';
import 'package:worker/features/reviews/presentation/providers/reviews_provider.dart';

class FilteredReviewsList extends ConsumerStatefulWidget {
  const FilteredReviewsList({super.key, required this.productId});

  final String productId;

  @override
  ConsumerState<FilteredReviewsList> createState() =>
      _FilteredReviewsListState();
}

class _FilteredReviewsListState extends ConsumerState<FilteredReviewsList> {
  int? _filterByStar; // null = all reviews

  List<Review> _filterReviews(List<Review> reviews) {
    if (_filterByStar == null) return reviews;

    return reviews.where((review) {
      return review.starsRating == _filterByStar;
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    final reviewsAsync = ref.watch(productReviewsProvider(widget.productId));

    return Column(
      children: [
        // Filter chips
        SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          padding: const EdgeInsets.all(8),
          child: Row(
            children: [
              FilterChip(
                label: const Text('All'),
                selected: _filterByStar == null,
                onSelected: (_) => setState(() => _filterByStar = null),
              ),
              const SizedBox(width: 8),
              for (int star = 5; star >= 1; star--)
                Padding(
                  padding: const EdgeInsets.only(right: 8),
                  child: FilterChip(
                    label: Row(
                      children: [
                        Text('$star'),
                        const Icon(Icons.star, size: 16),
                      ],
                    ),
                    selected: _filterByStar == star,
                    onSelected: (_) => setState(() => _filterByStar = star),
                  ),
                ),
            ],
          ),
        ),

        // Reviews list
        Expanded(
          child: reviewsAsync.when(
            data: (reviews) {
              final filteredReviews = _filterReviews(reviews);

              if (filteredReviews.isEmpty) {
                return const Center(
                  child: Text('No reviews match the filter'),
                );
              }

              return ListView.builder(
                itemCount: filteredReviews.length,
                itemBuilder: (context, index) {
                  final review = filteredReviews[index];
                  return ListTile(
                    title: Text(review.reviewerName ?? 'Anonymous'),
                    subtitle: Text(review.comment),
                  );
                },
              );
            },
            loading: () => const Center(
              child: CircularProgressIndicator(),
            ),
            error: (error, stack) => Center(
              child: Text('Error: $error'),
            ),
          ),
        ),
      ],
    );
  }
}

Error Handling

Comprehensive Error Display

import 'package:flutter/material.dart';
import 'package:worker/core/errors/exceptions.dart';

Widget buildErrorWidget(Object error) {
  String title;
  String message;
  IconData icon;
  Color color;

  if (error is NoInternetException) {
    title = 'No Internet Connection';
    message = 'Please check your internet connection and try again.';
    icon = Icons.wifi_off;
    color = Colors.orange;
  } else if (error is TimeoutException) {
    title = 'Request Timeout';
    message = 'The request took too long. Please try again.';
    icon = Icons.timer_off;
    color = Colors.orange;
  } else if (error is UnauthorizedException) {
    title = 'Session Expired';
    message = 'Please log in again to continue.';
    icon = Icons.lock_outline;
    color = Colors.red;
  } else if (error is ServerException) {
    title = 'Server Error';
    message = 'Something went wrong on our end. Please try again later.';
    icon = Icons.error_outline;
    color = Colors.red;
  } else if (error is ValidationException) {
    title = 'Invalid Data';
    message = error.message;
    icon = Icons.warning_amber;
    color = Colors.orange;
  } else {
    title = 'Unknown Error';
    message = error.toString();
    icon = Icons.error;
    color = Colors.red;
  }

  return Center(
    child: Padding(
      padding: const EdgeInsets.all(40),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(icon, size: 64, color: color),
          const SizedBox(height: 16),
          Text(
            title,
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
              color: color,
            ),
            textAlign: TextAlign.center,
          ),
          const SizedBox(height: 8),
          Text(
            message,
            style: const TextStyle(fontSize: 14),
            textAlign: TextAlign.center,
          ),
        ],
      ),
    ),
  );
}

Retry Logic

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/features/reviews/presentation/providers/reviews_provider.dart';

class ReviewsWithRetry extends ConsumerWidget {
  const ReviewsWithRetry({super.key, required this.productId});

  final String productId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final reviewsAsync = ref.watch(productReviewsProvider(productId));

    return reviewsAsync.when(
      data: (reviews) {
        // Show reviews
        return ListView.builder(
          itemCount: reviews.length,
          itemBuilder: (context, index) {
            final review = reviews[index];
            return ListTile(
              title: Text(review.reviewerName ?? 'Anonymous'),
              subtitle: Text(review.comment),
            );
          },
        );
      },
      loading: () => const Center(
        child: CircularProgressIndicator(),
      ),
      error: (error, stack) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error, size: 64, color: Colors.red),
            const SizedBox(height: 16),
            Text('Error: $error'),
            const SizedBox(height: 16),
            ElevatedButton.icon(
              onPressed: () {
                // Retry by invalidating provider
                ref.invalidate(productReviewsProvider(productId));
              },
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
            ),
          ],
        ),
      ),
    );
  }
}

Custom Widgets

Custom Review Card

import 'package:flutter/material.dart';
import 'package:worker/features/reviews/domain/entities/review.dart';

class ReviewCard extends StatelessWidget {
  const ReviewCard({super.key, required this.review});

  final Review review;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Header: Avatar + Name + Date
            Row(
              children: [
                CircleAvatar(
                  child: Text(
                    review.reviewerName?.substring(0, 1).toUpperCase() ?? '?',
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        review.reviewerName ?? 'Anonymous',
                        style: const TextStyle(
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      if (review.reviewDate != null)
                        Text(
                          _formatDate(review.reviewDate!),
                          style: TextStyle(
                            fontSize: 12,
                            color: Colors.grey[600],
                          ),
                        ),
                    ],
                  ),
                ),
              ],
            ),

            const SizedBox(height: 12),

            // Star rating
            Row(
              children: List.generate(5, (index) {
                return Icon(
                  index < review.starsRating ? Icons.star : Icons.star_border,
                  size: 20,
                  color: Colors.amber,
                );
              }),
            ),

            const SizedBox(height: 12),

            // Comment
            Text(
              review.comment,
              style: const TextStyle(height: 1.5),
            ),
          ],
        ),
      ),
    );
  }

  String _formatDate(DateTime date) {
    final now = DateTime.now();
    final diff = now.difference(date);

    if (diff.inDays == 0) return 'Today';
    if (diff.inDays == 1) return 'Yesterday';
    if (diff.inDays < 7) return '${diff.inDays} days ago';
    if (diff.inDays < 30) return '${(diff.inDays / 7).floor()} weeks ago';
    if (diff.inDays < 365) return '${(diff.inDays / 30).floor()} months ago';
    return '${(diff.inDays / 365).floor()} years ago';
  }
}

Star Rating Selector Widget

import 'package:flutter/material.dart';

class StarRatingSelector extends StatelessWidget {
  const StarRatingSelector({
    super.key,
    required this.rating,
    required this.onRatingChanged,
    this.size = 40,
    this.color = Colors.amber,
  });

  final int rating;
  final ValueChanged<int> onRatingChanged;
  final double size;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: List.generate(5, (index) {
        final star = index + 1;
        return GestureDetector(
          onTap: () => onRatingChanged(star),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 4),
            child: Icon(
              star <= rating ? Icons.star : Icons.star_border,
              size: size,
              color: color,
            ),
          ),
        );
      }),
    );
  }
}

Testing Examples

Unit Test for Review Entity

import 'package:flutter_test/flutter_test.dart';
import 'package:worker/features/reviews/domain/entities/review.dart';

void main() {
  group('Review Entity', () {
    test('starsRating converts API rating (0-1) to stars (1-5) correctly', () {
      expect(const Review(
        id: 'test',
        itemId: 'item1',
        rating: 0.2,
        comment: 'Test',
      ).starsRating, equals(1));

      expect(const Review(
        id: 'test',
        itemId: 'item1',
        rating: 0.5,
        comment: 'Test',
      ).starsRating, equals(3)); // 2.5 rounds to 3

      expect(const Review(
        id: 'test',
        itemId: 'item1',
        rating: 1.0,
        comment: 'Test',
      ).starsRating, equals(5));
    });

    test('starsRatingDecimal returns exact decimal value', () {
      expect(const Review(
        id: 'test',
        itemId: 'item1',
        rating: 0.8,
        comment: 'Test',
      ).starsRatingDecimal, equals(4.0));
    });
  });
}

Widget Test for Review Card

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:worker/features/reviews/domain/entities/review.dart';
// Import your ReviewCard widget

void main() {
  testWidgets('ReviewCard displays review data correctly', (tester) async {
    final review = Review(
      id: 'test-1',
      itemId: 'item-1',
      rating: 0.8, // 4 stars
      comment: 'Great product!',
      reviewerName: 'John Doe',
      reviewDate: DateTime.now().subtract(const Duration(days: 2)),
    );

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: ReviewCard(review: review),
        ),
      ),
    );

    // Verify reviewer name is displayed
    expect(find.text('John Doe'), findsOneWidget);

    // Verify comment is displayed
    expect(find.text('Great product!'), findsOneWidget);

    // Verify star icons (4 filled, 1 empty)
    expect(find.byIcon(Icons.star), findsNWidgets(4));
    expect(find.byIcon(Icons.star_border), findsOneWidget);

    // Verify date is displayed
    expect(find.textContaining('days ago'), findsOneWidget);
  });
}

Integration Test for Submit Review

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
// Import your widgets and mocks

void main() {
  testWidgets('Submit review flow', (tester) async {
    // Setup mock repository
    final mockRepository = MockReviewsRepository();
    when(mockRepository.submitReview(
      itemId: anyNamed('itemId'),
      rating: anyNamed('rating'),
      comment: anyNamed('comment'),
    )).thenAnswer((_) async {});

    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          reviewsRepositoryProvider.overrideWithValue(mockRepository),
        ],
        child: MaterialApp(
          home: WriteReviewPage(productId: 'test-product'),
        ),
      ),
    );

    // Tap the 5th star
    await tester.tap(find.byIcon(Icons.star_border).last);
    await tester.pump();

    // Enter comment
    await tester.enterText(
      find.byType(TextField),
      'This is a great product! I highly recommend it.',
    );
    await tester.pump();

    // Tap submit button
    await tester.tap(find.widgetWithText(ElevatedButton, 'Submit'));
    await tester.pumpAndSettle();

    // Verify submit was called with correct parameters
    verify(mockRepository.submitReview(
      itemId: 'test-product',
      rating: 1.0, // 5 stars = 1.0 API rating
      comment: 'This is a great product! I highly recommend it.',
    )).called(1);

    // Verify success message is shown
    expect(find.text('Review submitted successfully!'), findsOneWidget);
  });
}

These examples cover the most common scenarios and can be adapted to your specific needs!