979 lines
26 KiB
Markdown
979 lines
26 KiB
Markdown
# Reviews API - Code Examples
|
|
|
|
## Table of Contents
|
|
1. [Basic Usage](#basic-usage)
|
|
2. [Advanced Scenarios](#advanced-scenarios)
|
|
3. [Error Handling](#error-handling)
|
|
4. [Custom Widgets](#custom-widgets)
|
|
5. [Testing Examples](#testing-examples)
|
|
|
|
---
|
|
|
|
## Basic Usage
|
|
|
|
### Display Reviews in a List
|
|
|
|
```dart
|
|
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: const CustomLoadingIndicator(),
|
|
),
|
|
error: (error, stack) => Center(
|
|
child: Text('Error: $error'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Show Average Rating
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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 const CustomLoadingIndicator()
|
|
: const Text('Submit Review'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Advanced Scenarios
|
|
|
|
### Paginated Reviews List
|
|
|
|
```dart
|
|
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 const CustomLoadingIndicator()
|
|
: 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
|
|
|
|
```dart
|
|
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: const CustomLoadingIndicator(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
error: (error, stack) => ListView(
|
|
children: [
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(40),
|
|
child: Text('Error: $error'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Filter Reviews by Rating
|
|
|
|
```dart
|
|
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: const CustomLoadingIndicator(),
|
|
),
|
|
error: (error, stack) => Center(
|
|
child: Text('Error: $error'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### Comprehensive Error Display
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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: const CustomLoadingIndicator(),
|
|
),
|
|
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
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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!
|