Skip to content

Commit 7ba3711

Browse files
committed
Refactor realtime and follow counts, Supabase
Manage realtime lifecycle and optimize follower queries, plus tighten Supabase debug fallback and update docs. - controllers/chat_controller.dart: added PetModel import; ensure realtime messages channel is unsubscribed and nulled on dispose and when active pet changes; listen to activePetProvider to load threads or reset ChatState, avoiding leaked channels and stale state. - controllers/feed_controller.dart: replace ad-hoc fetch/subscription logic with an authProvider listener that disposes/resubscribes like/comment channels on logout/login, triggers fetchPosts on user change, and returns a clean unauthenticated FeedState to prevent duplicate realtime subscriptions. - repositories/follow_repository.dart: use server-side count() for scalar follower/following counts; introduce fetchPetFollowerCounts to batch pet owner lookups and follower queries, deduplicate owner vs pet followers, and eliminate N+1/full-table scan patterns. - utils/supabase_config.dart: add SUPABASE_ALLOW_EMBEDDED_DEBUG_FALLBACK dart-define (default true) and fail fast in non-release builds when fallbacks are disabled, improving security hygiene; provide clearer error messages for missing env defines. - docs: update architecture and feature-practices docs with a May 7, 2026 implementation/status snapshot reflecting shipped fixes and remaining open items. These changes reduce realtime duplication/leaks, improve follower-count performance, and harden local/release Supabase configuration.
1 parent f8056ec commit 7ba3711

9 files changed

Lines changed: 342 additions & 78 deletions

docs/01_CODEBASE_ARCHITECTURE_REVIEW.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,24 @@ PetSphere is a **feature-rich pet-centric social and marketplace platform** buil
2121
- FCM push notification integration
2222

2323
**Critical Issues Identified:**
24-
- 12 screens are mock/stub implementations with no backend integration
25-
- In-memory cart has no persistence (data loss on app kill)
26-
- Missing order payment processing integration
27-
- No offline support except care logs
28-
- Database schema not version-controlled in repository
29-
- Cross-ownership of appointment state between controllers
24+
- Multiple screens remain mock/stub implementations (prioritize by product value).
25+
- Offline support remains limited beyond care-log paths.
26+
- Long-tail health/marketplace/social backlog items persist (see Issues & Gaps).
27+
28+
### Implementation status (engineering snapshot — May 7, 2026)
29+
30+
| Topic | Documentation / issue snapshot | Implemented in codebase |
31+
|--------|--------------------------------|-------------------------|
32+
| Cart persistence | “In-memory cart” in executive summary below | Done — SharedPreferences-backed cart serialization (`CartNotifier`). |
33+
| Checkout / Stripe | Payments missing in older review bullets | Done — Stripe client flow, `create-payment-intent` Edge Function, orders payment fields migration. |
34+
| Push / deep links | FCM wiring | Done — coordinators + `push_deeplink_routes.dart` mapping with fallback. |
35+
| Supabase migrations | “Not version-controlled” | Superseded — `supabase/migrations/` tracked; apply via CLI against each environment. |
36+
| Feed realtime lifecycle | Duplicate / leaked channels across logout | Done — likes/comments channels unsubscribed when logged out; resubscribed when session returns; avoids duplicate realtime when rebuild leaves channels intact. |
37+
| Follow counts (`N+1` / full-table scans) | High query cost for follower metrics | Done — HEAD `count()` where applicable; batched counts on discovery refresh; swipe + Nearby UI read `MatchState.discoveryFollowerCounts` (no per-card `FutureProvider` fan-out). |
38+
| Embedded Supabase fallback (debug keys in repo) | Security hygiene | Mitigated — `SUPABASE_ALLOW_EMBEDDED_DEBUG_FALLBACK` dart-define defaults to permissive behavior; set `false` + pass URL/anon key for stricter setups. |
39+
| Stub / placeholder screens | 12+ mocks | Partially tracked in GitHub; still open scope-by-scope. |
40+
41+
Treat the detailed narrative later in this document as historical diagnosis unless it conflicts with this table.
3042

3143
---
3244

docs/02_FEATURES_REQUIREMENTS_BEST_PRACTICES.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,26 @@ Future<OrderModel> placeOrder(CartState cart) async {
11121112

11131113
## Implementation Issues & Fixes
11141114

1115+
### Rolling implementation status (May 7, 2026)
1116+
1117+
Historical issue descriptions in this section pre-date several shipped fixes. Use this subsection as the ground truth for what is done vs still open relative to GitHub/issue threads.
1118+
1119+
| ID / theme | Status | Notes |
1120+
|------------|--------|--------|
1121+
| Cart persistence (#23-class) | **Done** | User-scoped `SharedPreferences` cart JSON; survives process death. |
1122+
| Stock validation / batch orders | **Done** | Repository-level checks surfaced to checkout UX. |
1123+
| Stripe / PaymentIntent (#32-class) | **Done** | Client + `create-payment-intent` Edge Function + orders payment columns migration. |
1124+
| FCM taps / foreground (#24-class) | **Done** | Initial message + opened-app routing helpers. |
1125+
| Story visibility / expiry (#40-class) | **Done** | `FeedState.visibleStories` + filtering. |
1126+
| Comment avatars (#42-class) | **Done** | Join + model field wired in post detail. |
1127+
| Appointment sync ↔ care (#25-class) | **Done** | `petCareProvider.refresh()` after upsert/cancel where applicable. |
1128+
| Feed realtime logout/login (#41-class) | **Done** | Unsubscribelikes/comments realtime on logout; resubscribe when authenticated; clearer empty state when logged out. |
1129+
| Follow follower `N+1` / heavy counts (#29-class) | **Done** | Repository uses `count()` where appropriate; discovery load batches counts via `fetchPetFollowerCounts` and surfaces them on swipe cards + Nearby list (`MatchState.discoveryFollowerCounts`). |
1130+
| Embedded debug Supabase URL/key (#48-class) | **Mitigated** | `SUPABASE_ALLOW_EMBEDDED_DEBUG_FALLBACK`; release still requires dart-defines (`assertValidReleaseSupabaseConfig`). |
1131+
| Stub screens (#33–37, #49–52, etc.) | **Open** | Implement per roadmap; docs below remain backlog narrative. |
1132+
| Offline / caching epic (#55-class) | **Open** | No change to scope here. |
1133+
| Care cache flicker (#39-class) | **Improved** | After network fetch, logs/weights lists reuse existing state when serialized payloads match baseline (cache or current UI), avoiding redundant rebuild churn when Supabase matches cache. |
1134+
11151135
### Critical Issues
11161136

11171137
#### Issue #1: Cart Persistence

lib/controllers/chat_controller.dart

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:developer' as developer;
33
import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import 'package:supabase_flutter/supabase_flutter.dart';
55
import '../models/chat_thread_model.dart';
6+
import '../models/pet_model.dart';
67
import '../models/message_model.dart';
78
import '../repositories/chat_repository.dart';
89
import 'pet_controller.dart';
@@ -145,12 +146,29 @@ class ChatController extends Notifier<ChatState> {
145146

146147
@override
147148
ChatState build() {
148-
ref.onDispose(() => _messagesChannel?.unsubscribe());
149-
final activePet = ref.watch(activePetProvider);
150-
if (activePet != null) {
151-
_loadThreads(activePet.id);
152-
}
153-
return ChatState(isLoading: true);
149+
ref.onDispose(() {
150+
_messagesChannel?.unsubscribe();
151+
_messagesChannel = null;
152+
});
153+
154+
ref.listen<PetModel?>(
155+
activePetProvider,
156+
(previous, next) {
157+
_messagesChannel?.unsubscribe();
158+
_messagesChannel = null;
159+
if (next == null) {
160+
Future.microtask(() {
161+
state = ChatState();
162+
});
163+
return;
164+
}
165+
_loadThreads(next.id);
166+
},
167+
fireImmediately: true,
168+
);
169+
170+
final activePet = ref.read(activePetProvider);
171+
return ChatState(isLoading: activePet != null);
154172
}
155173

156174
Future<void> _loadThreads(String myPetId) async {

lib/controllers/feed_controller.dart

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,30 +56,54 @@ class FeedNotifier extends Notifier<FeedState> {
5656

5757
@override
5858
FeedState build() {
59-
// Auto-refetch the feed whenever the auth status flips to authenticated
60-
// or the active user changes. This guarantees fresh content on cold
61-
// start (saved session) and on fresh login without forcing the user to
62-
// pull-to-refresh. Manual refresh via `refresh()` is still available.
63-
ref.listen(authProvider, (prev, next) {
64-
if (next.status == AuthStatus.authenticated &&
65-
next.user != null &&
66-
_lastFetchedForUserId != next.user!.id) {
67-
_lastFetchedForUserId = next.user!.id;
68-
_fetchPosts();
69-
} else if (next.status == AuthStatus.unauthenticated) {
70-
_lastFetchedForUserId = null;
71-
}
72-
});
73-
74-
_setupRealtimeSubscriptions();
7559
ref.onDispose(_disposeChannels);
76-
_fetchPosts();
77-
final authedUser = ref.read(authProvider).user;
78-
if (authedUser != null) _lastFetchedForUserId = authedUser.id;
60+
61+
ref.listen<AuthState>(
62+
authProvider,
63+
(prev, next) {
64+
if (next.status == AuthStatus.unauthenticated) {
65+
_disposeChannels();
66+
_likesChannel = null;
67+
_commentsChannel = null;
68+
_lastFetchedForUserId = null;
69+
return;
70+
}
71+
if (next.status == AuthStatus.authenticated && next.user != null) {
72+
final uid = next.user!.id;
73+
if (_lastFetchedForUserId != uid) {
74+
_lastFetchedForUserId = uid;
75+
_fetchPosts();
76+
}
77+
if (_likesChannel == null || _commentsChannel == null) {
78+
_ensureRealtimeSubscribed();
79+
}
80+
}
81+
},
82+
fireImmediately: true,
83+
);
84+
85+
final auth = ref.read(authProvider);
86+
if (auth.status != AuthStatus.authenticated || auth.user == null) {
87+
_disposeChannels();
88+
_likesChannel = null;
89+
_commentsChannel = null;
90+
_lastFetchedForUserId = null;
91+
return FeedState(
92+
isLoading: false,
93+
posts: const [],
94+
stories: const [],
95+
error: null,
96+
);
97+
}
7998
return FeedState(isLoading: true);
8099
}
81100

82-
void _setupRealtimeSubscriptions() {
101+
void _ensureRealtimeSubscribed() {
102+
final authed = ref.read(authProvider).status == AuthStatus.authenticated &&
103+
ref.read(authProvider).user != null;
104+
if (!authed) return;
105+
_likesChannel?.unsubscribe();
106+
_commentsChannel?.unsubscribe();
83107
_likesChannel = feedRepository.subscribeToLikes(
84108
onLikeChange: _handleRealtimeLike,
85109
);

lib/controllers/match_controller.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
33
import '../models/match_request_model.dart';
44
import '../models/pet_model.dart';
5+
import '../repositories/follow_repository.dart';
56
import '../repositories/match_repository.dart';
67
import '../repositories/notification_repository.dart';
78
import 'auth_controller.dart';
@@ -32,6 +33,8 @@ class MatchState {
3233
final List<PetModel> _allDiscoveryPets; // unfiltered set for search
3334
final List<MatchRequestModel> myRequests;
3435
final List<MatchRequestModel> sentRequests;
36+
/// Batched follower counts for pets on the discovery feed (Issue #29).
37+
final Map<String, int> discoveryFollowerCounts;
3538
final bool isLoading;
3639
final String? filterAnimal;
3740
final String? filterBreed;
@@ -43,6 +46,7 @@ class MatchState {
4346
List<PetModel>? allDiscoveryPets,
4447
this.myRequests = const [],
4548
this.sentRequests = const [],
49+
this.discoveryFollowerCounts = const {},
4650
this.isLoading = false,
4751
this.filterAnimal,
4852
this.filterBreed,
@@ -57,6 +61,7 @@ class MatchState {
5761
List<PetModel>? allDiscoveryPets,
5862
List<MatchRequestModel>? myRequests,
5963
List<MatchRequestModel>? sentRequests,
64+
Map<String, int>? discoveryFollowerCounts,
6065
bool? isLoading,
6166
String? filterAnimal,
6267
String? filterBreed,
@@ -71,6 +76,8 @@ class MatchState {
7176
allDiscoveryPets: allDiscoveryPets ?? _allDiscoveryPets,
7277
myRequests: myRequests ?? this.myRequests,
7378
sentRequests: sentRequests ?? this.sentRequests,
79+
discoveryFollowerCounts:
80+
discoveryFollowerCounts ?? this.discoveryFollowerCounts,
7481
isLoading: isLoading ?? this.isLoading,
7582
filterAnimal: clearAnimal ? null : (filterAnimal ?? this.filterAnimal),
7683
filterBreed: clearBreed ? null : (filterBreed ?? this.filterBreed),
@@ -122,6 +129,7 @@ class MatchController extends Notifier<MatchState> {
122129
filterAnimal: state.filterAnimal,
123130
filterBreed: state.filterBreed,
124131
searchQuery: state.searchQuery,
132+
discoveryFollowerCounts: state.discoveryFollowerCounts,
125133
discoveryPets: const [],
126134
allDiscoveryPets: const [],
127135
myRequests: state.myRequests,
@@ -182,11 +190,24 @@ class MatchController extends Notifier<MatchState> {
182190
final allPets = futures[0] as List<PetModel>;
183191
final filtered = _applySearchFilter(allPets, state.searchQuery);
184192

193+
Map<String, int> followerCounts = {};
194+
try {
195+
if (allPets.isNotEmpty) {
196+
followerCounts =
197+
await followRepository.fetchPetFollowerCounts(allPets.map((p) => p.id));
198+
}
199+
} catch (e, st) {
200+
debugPrint('[MatchController] follower counts batch skipped: $e\n$st');
201+
}
202+
203+
if (gen != _loadGeneration) return;
204+
185205
state = state.copyWith(
186206
discoveryPets: filtered,
187207
allDiscoveryPets: allPets,
188208
myRequests: futures[1] as List<MatchRequestModel>,
189209
sentRequests: futures[2] as List<MatchRequestModel>,
210+
discoveryFollowerCounts: followerCounts,
190211
isLoading: false,
191212
);
192213
} catch (e) {
@@ -323,6 +344,10 @@ class MatchController extends Notifier<MatchState> {
323344
state = state.copyWith(
324345
discoveryPets: discoveryPets,
325346
allDiscoveryPets: allDiscoveryPets,
347+
discoveryFollowerCounts: {
348+
for (final e in state.discoveryFollowerCounts.entries)
349+
if (e.key != receiverPetId) e.key: e.value,
350+
},
326351
);
327352

328353
// Notify the receiver pet's owner

lib/controllers/pet_care_controller.dart

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:convert';
23

34
import 'package:flutter/foundation.dart';
45
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -14,6 +15,12 @@ import '../utils/care_personalization.dart';
1415
export '../models/pet_health_models.dart' show PetSymptom;
1516
import 'pet_controller.dart';
1617

18+
String _stableCareLogsSig(List<PetCareLog> logs) =>
19+
jsonEncode(logs.map((l) => l.toUpsertJson()).toList());
20+
21+
String _stableCareWeightsSig(List<PetWeightLog> logs) =>
22+
jsonEncode(logs.map((l) => l.toUpsertJson()).toList());
23+
1724
// ---------------------------------------------------------------------------
1825
// State
1926
// ---------------------------------------------------------------------------
@@ -169,6 +176,15 @@ class PetCareNotifier extends Notifier<PetCareState> {
169176

170177
if (gen != _loadGen) return;
171178

179+
final logsBaselineSig = cachedLogs.isNotEmpty
180+
? _stableCareLogsSig(cachedLogs)
181+
: (state.activePetId == pet.id ? _stableCareLogsSig(state.recentLogs) : '');
182+
final weightsBaselineSig = cachedWeights.isNotEmpty
183+
? _stableCareWeightsSig(cachedWeights)
184+
: (state.activePetId == pet.id
185+
? _stableCareWeightsSig(state.recentWeights)
186+
: '');
187+
172188
if (cachedLogs.isNotEmpty || cachedWeights.isNotEmpty) {
173189
state = state.copyWith(
174190
activePetId: pet.id,
@@ -211,13 +227,18 @@ class PetCareNotifier extends Notifier<PetCareState> {
211227
final onboarding = results[5] as PetCareOnboarding?;
212228
freshLogs = applyOnboardingToCareLogs(freshLogs, onboarding);
213229

230+
final reuseLogs = logsBaselineSig.isNotEmpty &&
231+
_stableCareLogsSig(freshLogs) == logsBaselineSig;
232+
final reuseWeights = weightsBaselineSig.isNotEmpty &&
233+
_stableCareWeightsSig(freshWeights) == weightsBaselineSig;
234+
214235
// ── 3. Write back to cache ───────────────────────────────────────────
215236
unawaited(CareCache.saveLogs(pet.id, freshLogs));
216237
unawaited(CareCache.saveWeights(pet.id, freshWeights));
217238

218239
state = state.copyWith(
219-
recentLogs: freshLogs,
220-
recentWeights: freshWeights,
240+
recentLogs: reuseLogs ? state.recentLogs : freshLogs,
241+
recentWeights: reuseWeights ? state.recentWeights : freshWeights,
221242
upcomingAppointments: results[2] as List<PetVetAppointment>,
222243
vaccinations: results[3] as List<PetVaccination>,
223244
symptoms: results[4] as List<PetSymptom>,

0 commit comments

Comments
 (0)