import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../brick/cache_helpers.dart'; import '../models/profile.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'auth_provider.dart'; import 'connectivity_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; const _kFaceCacheEnrolledAtPrefix = 'face_cache_enrolled_at:'; /// Reads cached enrolled-face bytes from app documents (mobile/desktop only). Future _readCachedEnrolledFace(String userId) async { if (kIsWeb) return null; try { final dir = await getApplicationDocumentsDirectory(); final file = File('${dir.path}/face_enrollment/$userId.jpg'); if (await file.exists()) { return await file.readAsBytes(); } } catch (_) {} return null; } /// Writes enrolled-face bytes to disk so face matching can run offline. /// Best-effort — never throws. Future _writeCachedEnrolledFace( String userId, Uint8List bytes, String? enrolledAt, ) async { if (kIsWeb) return; try { final dir = await getApplicationDocumentsDirectory(); final cacheDir = Directory('${dir.path}/face_enrollment'); if (!await cacheDir.exists()) { await cacheDir.create(recursive: true); } final file = File('${cacheDir.path}/$userId.jpg'); await file.writeAsBytes(bytes); if (enrolledAt != null) { final prefs = await SharedPreferences.getInstance(); await prefs.setString('$_kFaceCacheEnrolledAtPrefix$userId', enrolledAt); } } catch (_) {} } /// Pre-warms the enrolled-face cache once per session if it is missing or /// out-of-date relative to the server's `face_enrolled_at` timestamp. /// No-op on web. Safe to call from a profile listener. Future prewarmEnrolledFaceCache({ required ProfileController controller, required String userId, required String? enrolledAt, }) async { if (kIsWeb || enrolledAt == null) return; try { final prefs = await SharedPreferences.getInstance(); final cachedAt = prefs.getString('$_kFaceCacheEnrolledAtPrefix$userId'); if (cachedAt == enrolledAt) return; // already up-to-date await controller.downloadFacePhoto(userId, enrolledAt: enrolledAt); } catch (_) {} } // --------------------------------------------------------------------------- // Offline-pending state // --------------------------------------------------------------------------- /// Pending full-name updates made offline, keyed by userId. final offlinePendingProfileUpdatesProvider = StateProvider>((ref) => const {}); final currentUserIdProvider = Provider((ref) { final authState = ref.watch(authStateChangesProvider); // Be explicit about loading/error to avoid dynamic dispatch problems. return authState.when( data: (state) => state.session?.user.id, loading: () => ref.watch(sessionProvider)?.user.id, error: (error, _) => ref.watch(sessionProvider)?.user.id, ); }); final currentProfileProvider = StreamProvider((ref) { final userId = ref.watch(currentUserIdProvider); if (userId == null) { return const Stream.empty(); } final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client.from('profiles').stream(primaryKey: ['id']).eq('id', userId), onPollData: () async { final data = await client .from('profiles') .select() .eq('id', userId) .maybeSingle(); return data == null ? [] : [Profile.fromMap(data)]; }, fromMap: Profile.fromMap, onOfflineData: () async { final all = await cachedListFromBrick(); return all.where((p) => p.id == userId).toList(); }, onCacheMirror: (rows) => mirrorBatchToBrick( rows.whereType().toList(), tag: 'profile_current', ), ); ref.onDispose(wrapper.dispose); ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; final pending = Map.from( ref.read(offlinePendingProfileUpdatesProvider), ); if (pending.isEmpty) return; debugPrint( '[currentProfileProvider] reconnected — syncing ${pending.length} profile update(s)', ); final synced = []; for (final entry in pending.entries) { try { await client .from('profiles') .update({'full_name': entry.value}) .eq('id', entry.key); synced.add(entry.key); } catch (e) { debugPrint( '[currentProfileProvider] failed to sync full_name for ${entry.key}: $e', ); } } if (synced.isNotEmpty) { final remaining = Map.from( ref.read(offlinePendingProfileUpdatesProvider), ); for (final id in synced) { remaining.remove(id); } ref.read(offlinePendingProfileUpdatesProvider.notifier).state = remaining; } }); return wrapper.stream.map((result) { return result.data.isEmpty ? null : result.data.first; }); }); final profilesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('profiles') .stream(primaryKey: ['id']) .order('full_name'), onPollData: () async { final data = await client.from('profiles').select().order('full_name'); return data.map(Profile.fromMap).toList(); }, fromMap: Profile.fromMap, onOfflineData: () async { final all = await cachedListFromBrick(); all.sort((a, b) => a.fullName.compareTo(b.fullName)); return all; }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'profiles'), ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); /// Controller for the current user's profile (update full name / password). final profileControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return ProfileController(client, ref); }); class ProfileController { ProfileController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; /// Update the `profiles.full_name` for the given user id. /// Offline: queues the update for replay on reconnect. Future updateFullName({ required String userId, required String fullName, }) async { try { await _client .from('profiles') .update({'full_name': fullName}) .eq('id', userId); } catch (e) { if (!isOfflineSaveError(e)) rethrow; final ref = _ref; if (ref == null) return; final current = Map.from( ref.read(offlinePendingProfileUpdatesProvider), ); current[userId] = fullName; ref.read(offlinePendingProfileUpdatesProvider.notifier).state = current; } } /// Update the current user's password (works for OAuth users too). Future updatePassword(String password) async { if (password.length < 8) { throw Exception('Password must be at least 8 characters'); } await _client.auth.updateUser(UserAttributes(password: password)); } /// Upload a profile avatar image and update the profile record. Future uploadAvatar({ required String userId, required Uint8List bytes, required String fileName, }) async { final ext = fileName.split('.').last.toLowerCase(); final path = '$userId/avatar.$ext'; await _client.storage .from('avatars') .uploadBinary( path, bytes, fileOptions: const FileOptions(upsert: true), ); final url = _client.storage.from('avatars').getPublicUrl(path); await _client.from('profiles').update({'avatar_url': url}).eq('id', userId); return url; } /// Upload a face enrollment photo and update the profile record. Future uploadFacePhoto({ required String userId, required Uint8List bytes, required String fileName, }) async { final ext = fileName.split('.').last.toLowerCase(); final path = '$userId/face.$ext'; await _client.storage .from('face-enrollment') .uploadBinary( path, bytes, fileOptions: const FileOptions(upsert: true), ); final url = _client.storage.from('face-enrollment').getPublicUrl(path); await _client .from('profiles') .update({ 'face_photo_url': url, 'face_enrolled_at': DateTime.now().toUtc().toIso8601String(), }) .eq('id', userId); return url; } /// Download the face enrollment photo bytes for the given user. /// Uses Supabase authenticated storage API (works with private buckets). /// /// On a successful online download, bytes are also written to a local cache /// (`${appDocs}/face_enrollment/.jpg`) so subsequent offline /// verification can read them. When the storage download fails (offline, /// auth refresh, etc.) we fall back to the cached file. Pass [enrolledAt] /// (the server's `face_enrolled_at` ISO timestamp) so the prewarm helper /// can detect re-enrollments and refresh the cache. Future downloadFacePhoto( String userId, { String? enrolledAt, }) async { try { final bytes = await _client.storage .from('face-enrollment') .download('$userId/face.jpg'); // Best-effort cache write so face matching can run offline next time. unawaited(_writeCachedEnrolledFace(userId, bytes, enrolledAt)); return bytes; } catch (_) { return await _readCachedEnrolledFace(userId); } } } final isAdminProvider = Provider((ref) { final profileAsync = ref.watch(currentProfileProvider); return profileAsync.maybeWhen( data: (profile) => profile?.role == 'admin' || profile?.role == 'programmer', orElse: () => false, ); });