Skip to content

Commit 6819f1a

Browse files
committed
Add search escaping, chat fixes, and theme boot
Multiple robustness and UX improvements: - New utils: escapeIlikePattern to safely escape ILIKE patterns and pendingBootstrapThemeMode for bootstrapping ThemeMode from SharedPreferences. - Main now reads saved theme into pendingBootstrapThemeMode to avoid light->dark flash; ThemeNotifier boots from that value and reconciles with prefs. - SearchRepository now escapes queries, clamps length, and uses safe ilike filters; updated select projection for posts. - Chat: added logging, safer realtime payload parsing, fetchThreadById for deep links, ThreadMessagesNotifier clears previous state on thread switch and guards against races, and ChatController.ensureThreadLoaded to merge single thread when list is stale. - Chat UI: improved init flow to refresh/ensure thread, added error/retry UI and back navigation handling, and adjusted header details. - Misc: added developer logging on various repository/controller failures, bounded list limits for marketplace/pets/orders, null-safe dental log getters, minor lifecycle checks (ref.mounted), and other small fixes.
1 parent acdcabd commit 6819f1a

17 files changed

Lines changed: 288 additions & 88 deletions

lib/controllers/auth_controller.dart

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,14 @@ class AuthNotifier extends Notifier<AuthState> {
4848
// Guard: prevent auth listener from overwriting state during active operations
4949
bool _isPerformingAuthAction = false;
5050

51+
/// Supabase auth stream uses the SDK type named `AuthState` (same name as our
52+
/// widget layer state class); keep this untyped to avoid import clashes.
53+
StreamSubscription<dynamic>? _authSubscription;
54+
5155
@override
5256
AuthState build() {
53-
_init();
54-
return AuthState();
55-
}
56-
57-
void _init() {
58-
// Listen to Supabase auth state changes continuously
59-
authRepository.authStateChanges.listen((event) async {
57+
ref.onDispose(() => _authSubscription?.cancel());
58+
_authSubscription ??= authRepository.authStateChanges.listen((event) async {
6059
// Skip if we're in the middle of login/register — those set state directly
6160
if (_isPerformingAuthAction) return;
6261

@@ -95,6 +94,8 @@ class AuthNotifier extends Notifier<AuthState> {
9594

9695
// Check current session immediately
9796
_checkCurrentSession();
97+
98+
return AuthState();
9899
}
99100

100101
Future<void> _checkCurrentSession() async {

lib/controllers/chat_controller.dart

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:developer' as developer;
2+
13
import 'package:flutter_riverpod/flutter_riverpod.dart';
24
import 'package:supabase_flutter/supabase_flutter.dart';
35
import '../models/chat_thread_model.dart';
@@ -25,19 +27,31 @@ class ThreadMessagesNotifier extends Notifier<List<MessageModel>> {
2527
Future<void> init(String threadId) async {
2628
_threadId = threadId;
2729

30+
// Clear immediately so a fast thread switch never shows the prior thread.
31+
state = [];
32+
_channel?.unsubscribe();
33+
_channel = null;
34+
2835
// Load initial messages
2936
try {
3037
final messages = await chatRepository.fetchMessages(threadId);
38+
if (_threadId != threadId) return;
3139
state = messages;
32-
} catch (_) {
33-
state = [];
40+
} catch (e, st) {
41+
developer.log(
42+
'fetchMessages failed',
43+
name: 'ThreadMessagesNotifier',
44+
error: e,
45+
stackTrace: st,
46+
);
47+
if (_threadId == threadId) state = [];
3448
}
3549

3650
// Subscribe to real-time updates
37-
_channel?.unsubscribe();
3851
_channel = chatRepository.subscribeToMessages(
3952
threadId: threadId,
4053
onMessage: (message) {
54+
if (_threadId != threadId) return;
4155
if (!state.any((m) => m.id == message.id)) {
4256
state = [...state, message];
4357
}
@@ -73,7 +87,13 @@ class ThreadMessagesNotifier extends Notifier<List<MessageModel>> {
7387

7488
// Message notifications are handled by the DB trigger
7589
// (notify_on_new_message) to avoid duplicates.
76-
} catch (_) {
90+
} catch (e, st) {
91+
developer.log(
92+
'sendMessage failed',
93+
name: 'ThreadMessagesNotifier',
94+
error: e,
95+
stackTrace: st,
96+
);
7797
// Rollback optimistic message
7898
state = state.where((m) => m.id != tempId).toList();
7999
}
@@ -197,6 +217,42 @@ class ChatController extends Notifier<ChatState> {
197217
}
198218
}
199219

220+
/// When navigating directly to `/chat/:id`, the thread may not yet appear in
221+
/// [state.threads]. Fetch that row (with participant pets) and merge it in.
222+
Future<void> ensureThreadLoaded(String threadId) async {
223+
if (state.threads.any((t) => t.id == threadId)) return;
224+
225+
final activePet = ref.read(activePetProvider);
226+
if (activePet == null) return;
227+
228+
try {
229+
final thread =
230+
await chatRepository.fetchThreadById(threadId, activePet.id);
231+
if (thread == null) return;
232+
233+
final unreadCounts = await chatRepository.fetchUnreadCountsForThreads(
234+
[threadId],
235+
activePet.id,
236+
);
237+
final merged = thread.copyWith(
238+
unreadCount: unreadCounts[threadId] ?? thread.unreadCount,
239+
);
240+
241+
if (state.threads.any((t) => t.id == threadId)) return;
242+
state = state.copyWith(
243+
threads: [merged, ...state.threads],
244+
);
245+
} catch (e, st) {
246+
developer.log(
247+
'ensureThreadLoaded failed',
248+
name: 'ChatController',
249+
error: e,
250+
stackTrace: st,
251+
);
252+
state = state.copyWith(error: 'Could not load conversation header.');
253+
}
254+
}
255+
200256
Future<void> markThreadAsRead(String threadId) async {
201257
final activePet = ref.read(activePetProvider);
202258
if (activePet == null) return;

lib/controllers/feed_controller.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:developer' as developer;
2+
13
import 'package:flutter_riverpod/flutter_riverpod.dart';
24
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthState;
35
import '../models/post_model.dart';
@@ -115,7 +117,14 @@ class FeedNotifier extends Notifier<FeedState> {
115117
return post.copyWith(comments: [...post.comments, comment]);
116118
}).toList(),
117119
);
118-
} catch (_) {}
120+
} catch (e, st) {
121+
developer.log(
122+
'Realtime comment fetch failed',
123+
name: 'FeedNotifier',
124+
error: e,
125+
stackTrace: st,
126+
);
127+
}
119128
}
120129

121130
Future<void> _fetchPosts() async {

lib/controllers/health_controller.dart

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,20 @@ class HealthState {
5555
return result;
5656
}
5757

58-
DentalLog? get lastHomeBrushing => dentalLogs.firstWhere(
59-
(d) => d.cleaningType == 'home_brushing',
60-
orElse: () => DentalLog(
61-
id: '',
62-
petId: '',
63-
logDate: DateTime(2000),
64-
cleaningType: 'home_brushing',
65-
),
66-
);
58+
DentalLog? get lastHomeBrushing {
59+
final matches = dentalLogs.where((d) => d.cleaningType == 'home_brushing').toList();
60+
if (matches.isEmpty) return null;
61+
matches.sort((a, b) => b.logDate.compareTo(a.logDate));
62+
return matches.first;
63+
}
6764

68-
DentalLog? get lastProfessionalCleaning => dentalLogs.firstWhere(
69-
(d) => d.cleaningType == 'professional_cleaning',
70-
orElse: () => DentalLog(
71-
id: '',
72-
petId: '',
73-
logDate: DateTime(2000),
74-
cleaningType: 'professional_cleaning',
75-
),
76-
);
65+
DentalLog? get lastProfessionalCleaning {
66+
final matches =
67+
dentalLogs.where((d) => d.cleaningType == 'professional_cleaning').toList();
68+
if (matches.isEmpty) return null;
69+
matches.sort((a, b) => b.logDate.compareTo(a.logDate));
70+
return matches.first;
71+
}
7772

7873
/// Number of active health alerts (overdue medications, parasite, etc.)
7974
int get alertCount {

lib/controllers/match_controller.dart

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,26 @@ class MatchState {
6969
// ---------------------------------------------------------------------------
7070
class MatchController extends Notifier<MatchState> {
7171
int _loadGeneration = 0; // cancel stale async loads
72+
String? _lastLoadedPetId;
7273

7374
@override
7475
MatchState build() {
7576
final activePet = ref.watch(activePetProvider);
76-
debugPrint('[MatchController] build: activePet=${activePet?.name}');
77-
if (activePet != null) {
78-
// Initial load — state already has filterAnimal/filterBreed as null
79-
_load(activePet.id);
77+
if (activePet == null) {
78+
_lastLoadedPetId = null;
79+
return MatchState();
80+
}
81+
82+
// Do not return a fresh [MatchState(isLoading: true)] on every rebuild —
83+
// that wipes discovery data whenever [activePetProvider] notifies with the
84+
// same pet id (new model instance). Only reset loading when the pet id changes.
85+
if (_lastLoadedPetId != activePet.id) {
86+
_lastLoadedPetId = activePet.id;
87+
Future.microtask(() => _load(activePet.id));
8088
return MatchState(isLoading: true);
8189
}
82-
return MatchState();
90+
91+
return state;
8392
}
8493

8594
Future<void> _load(String myPetId) async {

lib/controllers/pet_care_controller.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ class PetCareNotifier extends Notifier<PetCareState> {
429429
if (today == null) return;
430430
try {
431431
final saved = await petCareRepository.upsertLog(today);
432+
if (!ref.mounted) return;
432433
// Keep the server-assigned id but otherwise prefer the local copy
433434
// (the user may have toggled more between save & response).
434435
final logs = state.recentLogs;

lib/controllers/search_controller.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,14 @@ class SearchState {
2828
bool? isLoading,
2929
String? error,
3030
String? query,
31+
bool clearError = false,
3132
}) {
3233
return SearchState(
3334
posts: posts ?? this.posts,
3435
pets: pets ?? this.pets,
3536
products: products ?? this.products,
3637
isLoading: isLoading ?? this.isLoading,
37-
error: error,
38+
error: clearError ? null : (error ?? this.error),
3839
query: query ?? this.query,
3940
);
4041
}
@@ -52,7 +53,7 @@ class SearchNotifier extends Notifier<SearchState> {
5253
return;
5354
}
5455

55-
state = state.copyWith(isLoading: true, query: query, error: null);
56+
state = state.copyWith(isLoading: true, query: query, clearError: true);
5657

5758
try {
5859
final results = await Future.wait([

lib/controllers/theme_controller.dart

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,25 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
33
import 'package:shared_preferences/shared_preferences.dart';
44

5+
import '../utils/theme_bootstrap.dart';
6+
57
const _kThemeModeKey = 'theme_mode';
68

79
class ThemeNotifier extends Notifier<ThemeMode> {
810
@override
911
ThemeMode build() {
10-
_loadSavedTheme();
11-
return ThemeMode.light;
12+
final boot = pendingBootstrapThemeMode ?? ThemeMode.light;
13+
pendingBootstrapThemeMode = null;
14+
Future.microtask(_reconcileWithPrefs);
15+
return boot;
1216
}
1317

14-
Future<void> _loadSavedTheme() async {
18+
Future<void> _reconcileWithPrefs() async {
1519
final prefs = await SharedPreferences.getInstance();
1620
final saved = prefs.getString(_kThemeModeKey);
17-
if (saved == 'dark') {
18-
state = ThemeMode.dark;
19-
} else {
20-
state = ThemeMode.light;
21-
}
21+
final mode =
22+
saved == 'dark' ? ThemeMode.dark : ThemeMode.light;
23+
if (state != mode) state = mode;
2224
}
2325

2426
Future<void> toggle() async {

lib/main.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import 'package:flutter/foundation.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter_riverpod/flutter_riverpod.dart';
4+
import 'package:shared_preferences/shared_preferences.dart';
45
import 'package:supabase_flutter/supabase_flutter.dart';
56
import 'package:marionette_flutter/marionette_flutter.dart';
67
import 'controllers/auth_controller.dart';
78
import 'controllers/bootstrap_controller.dart';
89
import 'controllers/theme_controller.dart';
910
import 'utils/routes.dart';
1011
import 'utils/supabase_config.dart';
12+
import 'utils/theme_bootstrap.dart';
1113
import 'theme/app_theme_v2_material3.dart';
1214

1315
/// Set via `flutter test integration_test/... --dart-define=INTEGRATION_TEST=true`
@@ -38,6 +40,10 @@ Future<void> main() async {
3840
anonKey: supabaseInitAnonKey,
3941
);
4042

43+
final prefs = await SharedPreferences.getInstance();
44+
pendingBootstrapThemeMode =
45+
prefs.getString('theme_mode') == 'dark' ? ThemeMode.dark : ThemeMode.light;
46+
4147
runApp(const ProviderScope(child: PetSphereApp()));
4248
}
4349

lib/repositories/chat_repository.dart

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:developer' as developer;
2+
13
import 'package:supabase_flutter/supabase_flutter.dart';
24
import '../models/chat_thread_model.dart';
35
import '../models/message_model.dart';
@@ -33,6 +35,30 @@ class ChatRepository {
3335
];
3436
}
3537

38+
/// Single thread row for deep links / chat screen when the list cache is stale.
39+
/// Enforces that [myPetId] participates in the thread (RLS should also enforce).
40+
Future<ChatThreadModel?> fetchThreadById(
41+
String threadId,
42+
String myPetId,
43+
) async {
44+
final data = await supabase
45+
.from('chat_threads')
46+
.select(
47+
'id, pet_id_1, pet_id_2, created_at, '
48+
'pet1:pets!pet_id_1(id, name, breed, animal_type, age, bio, profile_image_url, images, is_public_owner, user_id), '
49+
'pet2:pets!pet_id_2(id, name, breed, animal_type, age, bio, profile_image_url, images, is_public_owner, user_id)',
50+
)
51+
.eq('id', threadId)
52+
.or('pet_id_1.eq.$myPetId,pet_id_2.eq.$myPetId')
53+
.maybeSingle();
54+
55+
if (data == null) return null;
56+
57+
final thread = ChatThreadModel.fromJson(data);
58+
final last = await _fetchLastMessage(threadId);
59+
return thread.copyWith(lastMessage: last);
60+
}
61+
3662
// -------------------------------------------------------------------------
3763
// Fetch all messages for a thread (oldest first)
3864
// -------------------------------------------------------------------------
@@ -120,7 +146,9 @@ class ChatRepository {
120146
}
121147

122148
// -------------------------------------------------------------------------
123-
// Subscribe to ALL new messages (filtered client-side) for thread list updates
149+
// Subscribe to ALL new message INSERT events for thread-list previews.
150+
// Payloads must be filtered by Supabase Realtime RLS to rows the user may read.
151+
// Client-side we ignore threads not in the known set (see [ChatController]).
124152
// -------------------------------------------------------------------------
125153
RealtimeChannel subscribeToAllMessages({
126154
required void Function(MessageModel message) onMessage,
@@ -135,7 +163,14 @@ class ChatRepository {
135163
try {
136164
final msg = MessageModel.fromJson(payload.newRecord);
137165
onMessage(msg);
138-
} catch (_) {}
166+
} catch (e, st) {
167+
developer.log(
168+
'Realtime message parse failed',
169+
name: 'ChatRepository',
170+
error: e,
171+
stackTrace: st,
172+
);
173+
}
139174
},
140175
)
141176
.subscribe();

0 commit comments

Comments
 (0)