Skip to content

Commit b574f70

Browse files
[SCRUM-47] Frontend profile reviews functionality (#45)
* Cleaning up saved_items.view, fixed issues with the profile button in the menu when selecting a profile that is not current user, updated post review endpoint in main * Working functionality for viewing reviews on a profile * Full functionality for viewing reviews as well as submitting them, submitted reviews must be between two participants that have had a conversation * fix error --------- Co-authored-by: Vallens <vallens.kho@gmail.com>
1 parent d5898c2 commit b574f70

File tree

12 files changed

+337
-51
lines changed

12 files changed

+337
-51
lines changed

backend/app/main.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,20 @@ async def post_reviews(
805805
for review in reviews:
806806
if current_user["id"] == review.get("reviewer_id"):
807807
raise HTTPException(
808-
status_code=404, detail="You have already left the user a review")
808+
status_code=404, detail="You have already left the user a review")
809+
810+
# check if the seller and the current user have a conversation
811+
try:
812+
user_id = ObjectId(current_user["id"])
813+
seller_id = ObjectId(body.seller_id)
814+
except Exception:
815+
raise HTTPException(status_code=400, detail="Invalid user or seller ID format.")
816+
817+
conversation_id = "_".join(sorted([str(user_id), str(seller_id)]))
818+
conversation = await messages_collection.find_one({"conversation_id": conversation_id})
819+
if not conversation:
820+
raise HTTPException(
821+
status_code=403, detail="You cannot leave a review without having a conversation with the seller")
809822

810823
review = {
811824
"seller_id": ObjectId(body.seller_id),
@@ -831,6 +844,9 @@ async def post_reviews(
831844

832845
return ReviewPostResponse(message="Review submitted successfully")
833846

847+
except HTTPException as e:
848+
raise HTTPException(
849+
status_code=e.status_code, detail=f"Error: {e.detail}")
834850
except Exception as e:
835851
raise HTTPException(
836852
status_code=500, detail=f"Internal Server Error: {str(e)}")
335 KB
Loading
2.86 MB
Loading
1.22 MB
Loading
2.86 MB
Loading

frontend/lib/profile/components/profile_actions.component.dart

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import 'package:flutter/material.dart';
22
import 'package:go_router/go_router.dart';
3+
import 'package:utm_marketplace/profile/view_models/profile.viewmodel.dart';
34

45
class ProfileActions extends StatelessWidget {
56
final bool isOwnProfile;
67
final VoidCallback onToggleView;
78
final bool showListings;
9+
final ProfileViewModel viewmodel;
810

911
const ProfileActions({
1012
super.key,
13+
required this.viewmodel,
1114
required this.isOwnProfile,
1215
required this.onToggleView,
1316
required this.showListings,
@@ -30,6 +33,126 @@ class ProfileActions extends StatelessWidget {
3033
),
3134
);
3235

36+
final addReviewButton = ElevatedButton(
37+
onPressed: () {
38+
showDialog(
39+
context: context,
40+
builder: (BuildContext context) {
41+
final TextEditingController reviewController =
42+
TextEditingController();
43+
int selectedRating = 0;
44+
45+
return AlertDialog(
46+
shape: RoundedRectangleBorder(
47+
borderRadius: BorderRadius.circular(20),
48+
),
49+
title: const Text('Write Review'),
50+
content: StatefulBuilder(
51+
builder: (BuildContext context, StateSetter setState) {
52+
return SizedBox(
53+
width: MediaQuery.of(context).size.width * 1,
54+
child: Column(
55+
mainAxisSize: MainAxisSize.min,
56+
children: [
57+
Row(
58+
mainAxisAlignment: MainAxisAlignment.center,
59+
children: List.generate(5, (index) {
60+
return IconButton(
61+
icon: Icon(
62+
selectedRating > index
63+
? Icons.star
64+
: Icons.star_border,
65+
color: Colors.amber,
66+
),
67+
onPressed: () {
68+
setState(() {
69+
selectedRating = index + 1;
70+
});
71+
},
72+
);
73+
}),
74+
),
75+
TextField(
76+
controller: reviewController,
77+
maxLines: 5,
78+
decoration: const InputDecoration(
79+
hintText: 'Enter your review here',
80+
border: OutlineInputBorder(),
81+
),
82+
),
83+
const SizedBox(height: 16),
84+
],
85+
),
86+
);
87+
},
88+
),
89+
actions: [
90+
Center(
91+
child: ElevatedButton(
92+
onPressed: () {
93+
if (selectedRating > 0) {
94+
viewmodel
95+
.submitReview(
96+
reviewController.text,
97+
selectedRating,
98+
)
99+
.then((_) {
100+
if (context.mounted) {
101+
if (viewmodel.errorMessage != null) {
102+
ScaffoldMessenger.of(context).showSnackBar(
103+
SnackBar(
104+
content: Text(viewmodel.errorMessage!),
105+
),
106+
);
107+
} else {
108+
ScaffoldMessenger.of(context).showSnackBar(
109+
const SnackBar(
110+
content:
111+
Text('Review submitted successfully'),
112+
),
113+
);
114+
}
115+
}
116+
});
117+
Navigator.of(context).pop();
118+
} else {
119+
ScaffoldMessenger.of(context).showSnackBar(
120+
const SnackBar(
121+
content: Text('Please select a rating'),
122+
),
123+
);
124+
}
125+
},
126+
style: ElevatedButton.styleFrom(
127+
backgroundColor: const Color(0xFF1E3765),
128+
shape: RoundedRectangleBorder(
129+
borderRadius: BorderRadius.circular(25),
130+
),
131+
),
132+
child: const Text(
133+
'Submit',
134+
style: TextStyle(color: Colors.white),
135+
),
136+
),
137+
),
138+
],
139+
);
140+
},
141+
);
142+
},
143+
style: ElevatedButton.styleFrom(
144+
backgroundColor: const Color(0xFF1E3765),
145+
minimumSize: const Size(double.infinity, 45),
146+
shape: RoundedRectangleBorder(
147+
borderRadius: BorderRadius.circular(25),
148+
),
149+
),
150+
child: const Text(
151+
'Add Review',
152+
style: TextStyle(color: Colors.white),
153+
),
154+
);
155+
33156
final listingsTab = GestureDetector(
34157
onTap: () {
35158
if (!showListings) onToggleView();
@@ -95,6 +218,11 @@ class ProfileActions extends StatelessWidget {
95218
padding: const EdgeInsets.symmetric(horizontal: 16.0),
96219
child: savedItemsButton,
97220
),
221+
if (!isOwnProfile)
222+
Padding(
223+
padding: const EdgeInsets.symmetric(horizontal: 16.0),
224+
child: addReviewButton,
225+
),
98226
const SizedBox(height: 4),
99227
Padding(
100228
padding: const EdgeInsets.symmetric(horizontal: 16.0),

frontend/lib/profile/model/profile.model.dart

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class ProfileModel {
77
final int ratingCount;
88
final String? location;
99
final List<String>? savedPosts;
10-
final List<Review> reviews;
10+
List<Review> reviews;
1111
final List<ListingItem> listings;
1212

1313
ProfileModel({
@@ -33,10 +33,27 @@ class ProfileModel {
3333
ratingCount: json['rating_count'] ?? 0,
3434
location: json['location'],
3535
savedPosts: (json['saved_posts'] as List?)?.cast<String>(),
36-
reviews: [], // Reviews are not part of the current API response
36+
reviews: [],
3737
listings: [], // Listings are not part of the current API response
3838
);
3939
}
40+
41+
Future<void> fetchReviews(Map<String, dynamic> reviewsJson) async {
42+
try {
43+
reviews = (reviewsJson['reviews'] as List<dynamic>)
44+
.map((reviewJson) => Review(
45+
reviewerId: reviewJson['reviewer_id'],
46+
reviewerName: '', // Name is not provided in the response
47+
reviewerImage: '', // Image is not provided in the response
48+
rating: reviewJson['rating'].toDouble(),
49+
comment: reviewJson['comment'] ?? '',
50+
date: DateTime.parse(reviewJson['timestamp']),
51+
))
52+
.toList();
53+
} catch (e) {
54+
throw Exception('Failed to fetch and set reviews: $e');
55+
}
56+
}
4057
}
4158

4259
class Review {

frontend/lib/profile/repository/profile.repository.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,73 @@ class ProfileRepository {
7373
}
7474
}
7575

76+
Future<Map<String, dynamic>> fetchReviews(String sellerId) async {
77+
try {
78+
// Get the JWT token from secure storage
79+
final token = await secureStorage.read(key: 'jwt_token');
80+
81+
if (token == null) {
82+
throw Exception('User is not authenticated');
83+
}
84+
85+
// If userId is "me", get the user ID from the token
86+
String targetUserId = sellerId;
87+
if (sellerId == 'me') {
88+
targetUserId = _getUserIdFromToken(token);
89+
}
90+
91+
// Make API call to fetch reviews
92+
final response = await dio.get(
93+
'/reviews',
94+
queryParameters: {'seller_id': targetUserId},
95+
options: Options(
96+
headers: {
97+
'Authorization': 'Bearer $token',
98+
},
99+
),
100+
);
101+
102+
return response.data;
103+
} catch (e) {
104+
throw Exception('Failed to fetch reviews: $e');
105+
}
106+
}
107+
108+
Future<int> submitReview(String sellerId, String review, int rating) async {
109+
try {
110+
// Get the JWT token from secure storage
111+
final token = await secureStorage.read(key: 'jwt_token');
112+
113+
if (token == null) {
114+
throw Exception('User is not authenticated');
115+
}
116+
117+
// Prepare review data
118+
final reviewData = {
119+
'seller_id': sellerId,
120+
'comment': review,
121+
'rating': rating,
122+
};
123+
124+
// Make API call to submit the review
125+
final response = await dio.post(
126+
'/reviews',
127+
data: reviewData,
128+
options: Options(
129+
headers: {
130+
'Authorization': 'Bearer $token',
131+
},
132+
),
133+
);
134+
135+
return response.statusCode ?? 0;
136+
} catch (e) {
137+
debugPrint('Error submitting review: $e');
138+
debugPrint("Error code: ${e is DioException ? (e).response?.statusCode : 0}");
139+
return e is DioException ? (e).response?.statusCode ?? 0 : 0;
140+
}
141+
}
142+
76143
Future<ProfileModel> updateProfile({
77144
required String userId,
78145
String? displayName,

frontend/lib/profile/view/profile.view.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class _ProfileState extends State<Profile> {
6060
),
6161
const SizedBox(height: 16),
6262
ProfileActions(
63+
viewmodel: profileViewModel,
6364
isOwnProfile: widget.isOwnProfile,
6465
onToggleView: profileViewModel.toggleView,
6566
showListings: profileViewModel.showListings,
@@ -244,6 +245,8 @@ class _ProfileState extends State<Profile> {
244245
leading: CircleAvatar(
245246
radius: 24,
246247
backgroundImage: NetworkImage(review.reviewerImage),
248+
onBackgroundImageError: (_, __) => setState(() {}),
249+
child: Icon(Icons.person, size: 24),
247250
),
248251
title: Row(
249252
mainAxisAlignment: MainAxisAlignment.spaceBetween,

frontend/lib/profile/view_models/profile.viewmodel.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ class ProfileViewModel extends LoadingViewModel {
3333
isLoading = true;
3434
notifyListeners();
3535
final result = await repo.fetchUserProfileById(userId);
36+
37+
final reviews = await repo.fetchReviews(userId);
38+
result.fetchReviews(reviews);
3639
_profileModel = result;
3740
notifyListeners();
3841
} catch (e) {
@@ -52,11 +55,38 @@ class ProfileViewModel extends LoadingViewModel {
5255
_imageVersion++;
5356

5457
await fetchUserProfileById(_profileModel!.id);
58+
59+
final reviews = await repo.fetchReviews(_profileModel!.id);
60+
_profileModel?.fetchReviews(reviews);
5561
} catch (e) {
5662
debugPrint('Error refreshing user data: ${e.toString()}');
5763
}
5864
}
5965

66+
Future<void> submitReview(String review, int rating) async {
67+
if (_profileModel == null) return;
68+
int result = 0;
69+
70+
try {
71+
_errorMessage = null;
72+
isLoading = true;
73+
notifyListeners();
74+
75+
result = await repo.submitReview(_profileModel!.id, review, rating);
76+
if (result == 403) {
77+
_errorMessage = "You must have spoken to the seller to leave a review.";
78+
}
79+
notifyListeners();
80+
} catch (e) {
81+
debugPrint('Error in submitReview: ${e.toString()} $result');
82+
_errorMessage = e.toString();
83+
notifyListeners();
84+
} finally {
85+
isLoading = false;
86+
notifyListeners();
87+
}
88+
}
89+
6090
Future<bool> updateProfile({
6191
required String userId,
6292
String? displayName,

0 commit comments

Comments
 (0)