Files
tasq/lib/providers/profile_provider.dart
T
2026-04-30 06:45:51 +08:00

310 lines
10 KiB
Dart

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<Uint8List?> _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<void> _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<void> 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<Map<String, String>>((ref) => const {});
final currentUserIdProvider = Provider<String?>((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<Profile?>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) {
return const Stream.empty();
}
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<Profile?>(
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<Profile>();
return all.where((p) => p.id == userId).toList();
},
onCacheMirror: (rows) => mirrorBatchToBrick<Profile>(
rows.whereType<Profile>().toList(),
tag: 'profile_current',
),
);
ref.onDispose(wrapper.dispose);
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = Map<String, String>.from(
ref.read(offlinePendingProfileUpdatesProvider),
);
if (pending.isEmpty) return;
debugPrint(
'[currentProfileProvider] reconnected — syncing ${pending.length} profile update(s)',
);
final synced = <String>[];
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<String, String>.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<List<Profile>>((ref) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<Profile>(
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<Profile>();
all.sort((a, b) => a.fullName.compareTo(b.fullName));
return all;
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Profile>(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<ProfileController>((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<void> 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<String, String>.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<void> 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<String> 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<String> 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/<userId>.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<Uint8List?> 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<bool>((ref) {
final profileAsync = ref.watch(currentProfileProvider);
return profileAsync.maybeWhen(
data: (profile) =>
profile?.role == 'admin' || profile?.role == 'programmer',
orElse: () => false,
);
});