Skip to content

Commit fd4bd5c

Browse files
committed
Merge branch 'cursor-review-implement' of https://github.com/CodeStorm-Hub/petsphere into cursor-review-implement
2 parents 74c5d96 + 9539c89 commit fd4bd5c

14 files changed

Lines changed: 925 additions & 119 deletions

lib/controllers/follow_controller.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,9 @@ class FollowController extends Notifier<void> {
136136

137137
final followControllerProvider =
138138
NotifierProvider<FollowController, void>(() => FollowController());
139+
140+
/// Follower list (user profiles) for a specific pet.
141+
final petFollowersListProvider = FutureProvider.family<
142+
List<Map<String, dynamic>>, String>((ref, petId) async {
143+
return followRepository.fetchPetFollowersList(petId);
144+
});

lib/controllers/match_controller.dart

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,19 @@ class MatchController extends Notifier<MatchState> {
9191
return state;
9292
}
9393

94-
Future<void> _load(String myPetId) async {
94+
// When [silent] is true the loading flag is NOT set, so the UI avoids a
95+
// full-screen spinner. Use this for background refreshes (e.g. after a like).
96+
Future<void> _load(String myPetId, {bool silent = false}) async {
9597
final gen = ++_loadGeneration;
9698
final userId = ref.read(authProvider).user?.id;
9799
if (userId == null) {
98-
state = state.copyWith(isLoading: false);
100+
if (!silent) state = state.copyWith(isLoading: false);
99101
return;
100102
}
101103

102-
state = state.copyWith(isLoading: true, clearError: true);
104+
if (!silent) state = state.copyWith(isLoading: true, clearError: true);
105+
106+
_currentDiscoveryPetId = myPetId;
103107

104108
try {
105109
final futures = await Future.wait([
@@ -143,6 +147,12 @@ class MatchController extends Notifier<MatchState> {
143147
/// screen so the global active pet remains unchanged.
144148
Future<void> load(String petId) async => _load(petId);
145149

150+
/// The pet ID that was most recently loaded into this controller.
151+
/// Tracks which pet the discovery screen is currently browsing for,
152+
/// even when it differs from the global [activePetProvider].
153+
String? get currentDiscoveryPetId => _currentDiscoveryPetId;
154+
String? _currentDiscoveryPetId;
155+
146156
void setFilterBreed(String? breed) {
147157
final activePet = ref.read(activePetProvider);
148158
debugPrint(
@@ -199,12 +209,32 @@ class MatchController extends Notifier<MatchState> {
199209
}).toList();
200210
}
201211

202-
Future<bool> sendLikeRequest(String receiverPetId) async {
203-
final activePet = ref.read(activePetProvider);
204-
if (activePet == null) return false;
212+
/// Send a like/match request from the currently-browsing pet to [receiverPetId].
213+
///
214+
/// [fromPetId] should be the pet selected in the Discovery tab
215+
/// (from [discoveryActivePetIdProvider]). It defaults to the global
216+
/// [activePetProvider] only as a fallback.
217+
Future<bool> sendLikeRequest(
218+
String receiverPetId, {
219+
String? fromPetId,
220+
}) async {
221+
// Resolve the sender: prefer the explicitly passed discovery pet,
222+
// fall back to _currentDiscoveryPetId, then the global active pet.
223+
final myPets = ref.read(petProvider).myPets;
224+
final targetId = fromPetId ?? _currentDiscoveryPetId;
225+
PetModel? senderPet;
226+
if (targetId != null) {
227+
try {
228+
senderPet = myPets.firstWhere((p) => p.id == targetId);
229+
} catch (_) {
230+
senderPet = null;
231+
}
232+
}
233+
senderPet ??= ref.read(activePetProvider);
234+
if (senderPet == null) return false;
205235

206236
// Prevent liking own pets
207-
final myPetIds = ref.read(petProvider).myPets.map((p) => p.id).toSet();
237+
final myPetIds = myPets.map((p) => p.id).toSet();
208238
if (myPetIds.contains(receiverPetId)) {
209239
state = state.copyWith(error: 'You cannot like your own pet.');
210240
return false;
@@ -221,9 +251,11 @@ class MatchController extends Notifier<MatchState> {
221251

222252
try {
223253
await matchRepository.sendLikeRequest(
224-
senderPetId: activePet.id,
254+
senderPetId: senderPet.id,
225255
receiverPetId: receiverPetId,
226256
);
257+
258+
// Optimistically remove the liked pet from the in-memory list.
227259
final discoveryPets =
228260
state.discoveryPets.where((p) => p.id != receiverPetId).toList();
229261
final allDiscoveryPets =
@@ -239,16 +271,17 @@ class MatchController extends Notifier<MatchState> {
239271
targetUserId: receiverPet.userId,
240272
title: 'New breeding interest',
241273
body:
242-
'${activePet.name} is interested in breeding with ${receiverPet.name}.',
274+
'${senderPet.name} is interested in breeding with ${receiverPet.name}.',
243275
type: 'match_request',
244276
entityType: 'match_request',
245-
entityId: activePet.id,
246-
actorPetId: activePet.id,
277+
entityId: senderPet.id,
278+
actorPetId: senderPet.id,
247279
);
248280
}
249281

250-
// Refresh sent requests
251-
_load(activePet.id);
282+
// Silent background refresh for the correct (discovery-selected) pet —
283+
// does NOT show a loading spinner, so the UI transition is seamless.
284+
_load(senderPet.id, silent: true);
252285
return true;
253286
} catch (e) {
254287
state = state.copyWith(error: 'Could not send request: $e');
@@ -296,3 +329,14 @@ class MatchController extends Notifier<MatchState> {
296329
final matchProvider = NotifierProvider<MatchController, MatchState>(() {
297330
return MatchController();
298331
});
332+
333+
/// Aggregated incoming match requests for ALL of the current user's pets.
334+
/// Used by the Notifications screen so the Requests tab always shows the
335+
/// full picture regardless of which pet is selected in the Discovery tab.
336+
final allMatchRequestsProvider =
337+
FutureProvider<List<MatchRequestModel>>((ref) async {
338+
final myPets = ref.watch(petProvider).myPets;
339+
if (myPets.isEmpty) return [];
340+
final petIds = myPets.map((p) => p.id).toList();
341+
return matchRepository.fetchAllMyRequests(petIds);
342+
});

lib/driver_main.dart

Lines changed: 0 additions & 7 deletions
This file was deleted.

lib/repositories/follow_repository.dart

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,72 @@ class FollowRepository {
155155
.eq('follower_user_id', userId);
156156
return (data as List).length;
157157
}
158+
159+
// -------------------------------------------------------------------------
160+
// Get list of follower user IDs (direct pet followers + owner followers)
161+
// Returns unique follower user IDs with basic user profile info via the
162+
// users table join.
163+
// -------------------------------------------------------------------------
164+
Future<List<Map<String, dynamic>>> fetchPetFollowersList(String petId) async {
165+
final pet = await supabase
166+
.from('pets')
167+
.select('user_id')
168+
.eq('id', petId)
169+
.maybeSingle();
170+
171+
if (pet == null) return [];
172+
final ownerUserId = pet['user_id'] as String;
173+
174+
final results = await Future.wait([
175+
// Direct pet followers
176+
supabase
177+
.from('follows')
178+
.select('follower_user_id, created_at')
179+
.not('followed_pet_id', 'is', null)
180+
.eq('followed_pet_id', petId),
181+
// Owner followers (implicit pet followers)
182+
supabase
183+
.from('follows')
184+
.select('follower_user_id, created_at')
185+
.not('followed_user_id', 'is', null)
186+
.eq('followed_user_id', ownerUserId),
187+
]);
188+
189+
final seen = <String>{};
190+
final combined = <Map<String, dynamic>>[];
191+
192+
for (final row in [...(results[0] as List), ...(results[1] as List)]) {
193+
final userId = row['follower_user_id'] as String;
194+
if (seen.add(userId)) {
195+
combined.add({'user_id': userId, 'created_at': row['created_at']});
196+
}
197+
}
198+
199+
if (combined.isEmpty) return [];
200+
201+
// Fetch user profiles for all follower IDs
202+
final followerIds = combined.map((r) => r['user_id'] as String).toList();
203+
final profiles = await supabase
204+
.from('users')
205+
.select('id, name, profile_image_url')
206+
.inFilter('id', followerIds);
207+
208+
final profileMap = {
209+
for (final p in profiles as List<dynamic>)
210+
(p as Map<String, dynamic>)['id'] as String: p,
211+
};
212+
213+
return combined.map((r) {
214+
final uid = r['user_id'] as String;
215+
final profile = profileMap[uid] ?? {'id': uid, 'name': 'Unknown', 'profile_image_url': ''};
216+
return {
217+
'user_id': uid,
218+
'name': (profile['name'] ?? 'Unknown') as String,
219+
'profile_image_url': (profile['profile_image_url'] ?? '') as String,
220+
'created_at': r['created_at'],
221+
};
222+
}).toList();
223+
}
158224
}
159225

160226
final followRepository = FollowRepository();

lib/repositories/match_repository.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,24 @@ class MatchRepository {
124124
.toList();
125125
}
126126

127+
// -------------------------------------------------------------------------
128+
// Fetch ALL match requests received by any of the user's pets
129+
// Used by the Notifications screen to show requests across all pets.
130+
// -------------------------------------------------------------------------
131+
Future<List<MatchRequestModel>> fetchAllMyRequests(List<String> myPetIds) async {
132+
if (myPetIds.isEmpty) return [];
133+
final data = await supabase
134+
.from('match_requests')
135+
.select('*, sender_pets:pets!sender_pet_id(*)')
136+
.inFilter('receiver_pet_id', myPetIds)
137+
.eq('status', 'pending')
138+
.order('created_at', ascending: false);
139+
140+
return (data as List<dynamic>)
141+
.map((e) => MatchRequestModel.fromJson(e as Map<String, dynamic>))
142+
.toList();
143+
}
144+
127145
// -------------------------------------------------------------------------
128146
// Accept or decline a match request
129147
// -------------------------------------------------------------------------

lib/utils/routes.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import '../views/pet_social_timeline_screen.dart';
4444
import '../views/pet_breed_identifier_screen.dart';
4545
import '../views/pet_knowledge_base_screen.dart';
4646
import '../views/pet_gear_reviews_screen.dart';
47+
import '../views/pet_followers_screen.dart';
4748

4849
final routerProvider = Provider<GoRouter>((ref) {
4950
final authNotifier = ValueNotifier<AuthState>(ref.read(authProvider));
@@ -198,6 +199,13 @@ final routerProvider = Provider<GoRouter>((ref) {
198199
path: '/search',
199200
builder: (context, state) => const SearchScreen(),
200201
),
202+
GoRoute(
203+
path: '/pet/:id/followers',
204+
builder: (context, state) {
205+
final petId = state.pathParameters['id']!;
206+
return PetFollowersScreen(petId: petId);
207+
},
208+
),
201209
GoRoute(
202210
path: '/achievements',
203211
builder: (context, state) => const GamificationScreen(),

lib/views/discovery_screen.dart

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,13 @@ class DiscoveryTabState extends ConsumerState<DiscoveryTab>
284284

285285
if (!liked) return;
286286

287-
ref.read(matchProvider.notifier).sendLikeRequest(pet.id).then((success) {
287+
// Pass the currently-selected discovery pet so the like is sent from
288+
// the correct pet (not the global active pet).
289+
final discoveryPetId = ref.read(discoveryActivePetIdProvider);
290+
ref
291+
.read(matchProvider.notifier)
292+
.sendLikeRequest(pet.id, fromPetId: discoveryPetId)
293+
.then((success) {
288294
if (!mounted) return;
289295
if (success) {
290296
ScaffoldMessenger.of(context).showSnackBar(
@@ -295,6 +301,7 @@ class DiscoveryTabState extends ConsumerState<DiscoveryTab>
295301
);
296302
return;
297303
}
304+
// On failure: re-show the pet by removing it from the dismissed set.
298305
setState(() => dismissedPetIds.remove(pet.id));
299306
final error = ref.read(matchProvider).error;
300307
ScaffoldMessenger.of(context).showSnackBar(
@@ -421,16 +428,25 @@ class DiscoveryTabState extends ConsumerState<DiscoveryTab>
421428
ref.listen<MatchState>(matchProvider, (prev, next) {
422429
if (!mounted) return;
423430
final petsChanged = prev?.discoveryPets != next.discoveryPets;
424-
// A load just finished with an empty feed for the selected pet.
425-
final loadFinishedEmpty =
426-
prev?.isLoading == true && !next.isLoading && next.discoveryPets.isEmpty;
431+
// A full load cycle just completed (isLoading transitioned false→false
432+
// via true, i.e. prev was loading and now it's done).
433+
final loadCycleFinished =
434+
prev?.isLoading == true && !next.isLoading;
435+
final loadFinishedEmpty = loadCycleFinished && next.discoveryPets.isEmpty;
427436

428437
if (!petsChanged && !loadFinishedEmpty) return;
429438

430439
final currentIds = next.discoveryPets.map((p) => p.id).toSet();
431440
setState(() {
432441
if (petsChanged) {
433-
dismissedPetIds.removeWhere((id) => !currentIds.contains(id));
442+
// Only purge dismissed IDs during a real load cycle (when
443+
// isLoading transitioned). Optimistic updates from sendLikeRequest
444+
// set petsChanged=true but keep isLoading=false throughout, so we
445+
// skip the purge there to avoid re-showing the just-dismissed pet
446+
// if a concurrent network fetch brings it back momentarily.
447+
if (loadCycleFinished) {
448+
dismissedPetIds.removeWhere((id) => !currentIds.contains(id));
449+
}
434450
_clampIndex(next.discoveryPets);
435451
}
436452
if (loadFinishedEmpty) {

0 commit comments

Comments
 (0)