164 lines
4.9 KiB
Dart
164 lines
4.9 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import '../models/profile.dart';
|
|
import 'auth_provider.dart';
|
|
import 'supabase_provider.dart';
|
|
import 'stream_recovery.dart';
|
|
|
|
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,
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
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,
|
|
);
|
|
|
|
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);
|
|
});
|
|
|
|
class ProfileController {
|
|
ProfileController(this._client);
|
|
|
|
final SupabaseClient _client;
|
|
|
|
/// Update the `profiles.full_name` for the given user id.
|
|
Future<void> updateFullName({
|
|
required String userId,
|
|
required String fullName,
|
|
}) async {
|
|
await _client
|
|
.from('profiles')
|
|
.update({'full_name': fullName})
|
|
.eq('id', userId);
|
|
}
|
|
|
|
/// 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).
|
|
Future<Uint8List?> downloadFacePhoto(String userId) async {
|
|
try {
|
|
return await _client.storage
|
|
.from('face-enrollment')
|
|
.download('$userId/face.jpg');
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
final isAdminProvider = Provider<bool>((ref) {
|
|
final profileAsync = ref.watch(currentProfileProvider);
|
|
return profileAsync.maybeWhen(
|
|
data: (profile) =>
|
|
profile?.role == 'admin' || profile?.role == 'programmer',
|
|
orElse: () => false,
|
|
);
|
|
});
|