import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../models/office.dart'; import '../../providers/auth_provider.dart' show sessionProvider; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/user_offices_provider.dart'; import '../../services/face_verification.dart' as face; import '../../widgets/multi_select_picker.dart'; import '../../widgets/qr_verification_dialog.dart'; import '../../widgets/responsive_body.dart'; import '../../utils/snackbar.dart'; class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @override ConsumerState createState() => _ProfileScreenState(); } class _ProfileScreenState extends ConsumerState { final _detailsKey = GlobalKey(); final _passwordKey = GlobalKey(); final _fullNameController = TextEditingController(); final _newPasswordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); List _selectedOfficeIds = []; bool _savingDetails = false; bool _changingPassword = false; bool _savingOffices = false; bool _uploadingAvatar = false; bool _enrollingFace = false; final _imagePicker = ImagePicker(); @override void dispose() { _fullNameController.dispose(); _newPasswordController.dispose(); _confirmPasswordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final profileAsync = ref.watch(currentProfileProvider); final officesAsync = ref.watch(officesProvider); final userOfficesAsync = ref.watch(userOfficesProvider); final userId = ref.watch(currentUserIdProvider); final session = ref.watch(sessionProvider); // Populate controllers from profile stream (if not editing) profileAsync.whenData((p) { final name = p?.fullName ?? ''; if (_fullNameController.text != name) { _fullNameController.text = name; } }); // Populate selected offices from userOfficesProvider final assignedOfficeIds = userOfficesAsync.valueOrNull ?.where((u) => u.userId == userId) .map((u) => u.officeId) .toList() ?? []; if (_selectedOfficeIds.isEmpty) { _selectedOfficeIds = List.from(assignedOfficeIds); } return ResponsiveBody( child: SingleChildScrollView( padding: const EdgeInsets.only(top: 16, bottom: 32), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('My Profile', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 12), // ── Avatar Card ── _buildAvatarCard(context, profileAsync), const SizedBox(height: 12), // ── Face & Biometric Enrollment Card ── _buildFaceEnrollmentCard(context, profileAsync), const SizedBox(height: 12), // Details Card Card( child: Padding( padding: const EdgeInsets.all(16), child: Form( key: _detailsKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Account details', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), // Email (read-only) TextFormField( initialValue: session?.user.email ?? '', decoration: const InputDecoration(labelText: 'Email'), readOnly: true, ), const SizedBox(height: 12), TextFormField( controller: _fullNameController, decoration: const InputDecoration( labelText: 'Full name', ), validator: (v) => (v ?? '').trim().isEmpty ? 'Full name is required' : null, ), const SizedBox(height: 12), Row( children: [ FilledButton( onPressed: _savingDetails ? null : _onSaveDetails, child: Text( _savingDetails ? 'Saving...' : 'Save details', ), ), ], ), ], ), ), ), ), const SizedBox(height: 12), // Change password Card Card( child: Padding( padding: const EdgeInsets.all(16), child: Form( key: _passwordKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Password', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), const Text( 'Set or change your password. OAuth users (Google/Meta) can set a password here.', ), const SizedBox(height: 12), TextFormField( controller: _newPasswordController, decoration: const InputDecoration( labelText: 'New password', ), obscureText: true, validator: (v) { if (v == null || v.isEmpty) { return null; // allow empty to skip } if ((v).length < 8) { return 'Password must be at least 8 characters'; } return null; }, ), const SizedBox(height: 8), TextFormField( controller: _confirmPasswordController, decoration: const InputDecoration( labelText: 'Confirm password', ), obscureText: true, validator: (v) { final pw = _newPasswordController.text; if (pw.isEmpty) { return null; } if (v != pw) { return 'Passwords do not match'; } return null; }, ), const SizedBox(height: 12), Row( children: [ FilledButton( onPressed: _changingPassword ? null : _onChangePassword, child: Text( _changingPassword ? 'Updating...' : 'Change password', ), ), ], ), ], ), ), ), ), const SizedBox(height: 12), // Offices Card Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Offices', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), officesAsync.when( data: (offices) { return Column( children: [ MultiSelectPicker( label: 'Offices', items: offices, selectedIds: _selectedOfficeIds, getId: (o) => o.id, getLabel: (o) => o.name, onChanged: (ids) => setState(() => _selectedOfficeIds = ids), ), const SizedBox(height: 12), Row( children: [ FilledButton( onPressed: _savingOffices ? null : _onSaveOffices, child: Text( _savingOffices ? 'Saving...' : 'Save offices', ), ), ], ), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Text('Failed to load offices: $e'), ), ], ), ), ), ], ), ), ); } Widget _buildAvatarCard(BuildContext context, AsyncValue profileAsync) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = profileAsync.valueOrNull; final avatarUrl = profile?.avatarUrl; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Profile Photo', style: theme.textTheme.titleMedium), const SizedBox(height: 16), Center( child: Stack( children: [ CircleAvatar( radius: 56, backgroundColor: colors.surfaceContainerHighest, backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, child: avatarUrl == null ? Icon( Icons.person, size: 48, color: colors.onSurfaceVariant, ) : null, ), if (_uploadingAvatar) const Positioned.fill( child: Center(child: CircularProgressIndicator()), ), ], ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FilledButton.tonalIcon( onPressed: _uploadingAvatar ? null : () => _pickAvatar(ImageSource.gallery), icon: const Icon(Icons.photo_library), label: const Text('Upload'), ), if (!kIsWeb) ...[ const SizedBox(width: 12), FilledButton.tonalIcon( onPressed: _uploadingAvatar ? null : () => _pickAvatar(ImageSource.camera), icon: const Icon(Icons.camera_alt), label: const Text('Camera'), ), ], ], ), ], ), ), ); } Widget _buildFaceEnrollmentCard( BuildContext context, AsyncValue profileAsync, ) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = profileAsync.valueOrNull; final hasFace = profile?.hasFaceEnrolled ?? false; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Face Verification', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Text( 'Enroll your face for attendance verification. ' 'A liveness check (blink detection) is required before enrollment.', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 16), // Face enrollment status Row( children: [ Icon( hasFace ? Icons.check_circle : Icons.cancel, color: hasFace ? Colors.green : colors.error, ), const SizedBox(width: 8), Expanded( child: Text( hasFace ? 'Face enrolled (${profile!.faceEnrolledAt != null ? _formatDate(profile.faceEnrolledAt!) : "unknown"})' : 'Face not enrolled', style: theme.textTheme.bodyMedium, ), ), ], ), const SizedBox(height: 12), FilledButton.icon( onPressed: _enrollingFace ? null : _enrollFace, icon: _enrollingFace ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.face), label: Text(hasFace ? 'Re-enroll Face' : 'Enroll Face'), ), // Test Facial Recognition const SizedBox(height: 16), const Divider(), const SizedBox(height: 12), Text('Test Facial Recognition', style: theme.textTheme.titleSmall), const SizedBox(height: 4), Text( hasFace ? 'Run a liveness check and compare with your enrolled face.' : 'Enroll your face first to test facial recognition.', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: hasFace ? _testFacialRecognition : null, icon: const Icon(Icons.face_retouching_natural), label: const Text('Test Facial Recognition'), ), ], ), ), ); } String _formatDate(DateTime dt) { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; } Future _pickAvatar(ImageSource source) async { try { final xFile = await _imagePicker.pickImage( source: source, maxWidth: 512, maxHeight: 512, imageQuality: 85, ); if (xFile == null) return; setState(() => _uploadingAvatar = true); final bytes = await xFile.readAsBytes(); final ext = xFile.name.split('.').last; final userId = ref.read(currentUserIdProvider); if (userId == null) return; await ref .read(profileControllerProvider) .uploadAvatar(userId: userId, bytes: bytes, fileName: 'avatar.$ext'); ref.invalidate(currentProfileProvider); if (mounted) showSuccessSnackBar(context, 'Avatar updated.'); } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed to upload avatar: $e'); } finally { if (mounted) setState(() => _uploadingAvatar = false); } } /// Face enrollment via liveness detection (works on both mobile and web). /// Mobile: uses flutter_liveness_check with blink detection. /// Web: uses face-api.js with camera and blink detection. /// Falls back to QR cross-device flow if web camera is unavailable. Future _enrollFace() async { if (!mounted) return; setState(() => _enrollingFace = true); try { final result = await face.runFaceLiveness(context); if (result == null) { // Cancelled or failed — on web, offer QR fallback if (kIsWeb && mounted) { final useQr = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Camera unavailable?'), content: const Text( 'If your camera did not work, you can enroll via your mobile device instead.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Use Mobile'), ), ], ), ); if (useQr == true && mounted) { final completed = await showQrVerificationDialog( context: context, ref: ref, type: 'enrollment', ); if (completed) ref.invalidate(currentProfileProvider); } } return; } // Upload the captured face photo final userId = ref.read(currentUserIdProvider); if (userId == null) return; await ref .read(profileControllerProvider) .uploadFacePhoto( userId: userId, bytes: result.imageBytes, fileName: 'face.jpg', ); ref.invalidate(currentProfileProvider); if (mounted) showSuccessSnackBar(context, 'Face enrolled successfully.'); } catch (e) { if (mounted) showErrorSnackBar(context, 'Face enrollment failed: $e'); } finally { if (mounted) setState(() => _enrollingFace = false); } } /// Test facial recognition: run liveness + compare with enrolled face. Future _testFacialRecognition() async { final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null || !profile.hasFaceEnrolled) { if (mounted) { showWarningSnackBar(context, 'Please enroll your face first.'); } return; } try { final result = await face.runFaceLiveness(context); if (result == null || !mounted) return; // Download enrolled photo via Supabase (authenticated, private bucket) final enrolledBytes = await ref .read(profileControllerProvider) .downloadFacePhoto(profile.id); if (enrolledBytes == null || !mounted) { if (mounted) { showErrorSnackBar(context, 'Could not load enrolled face photo.'); } return; } // Compare captured vs enrolled final score = await face.compareFaces(result.imageBytes, enrolledBytes); if (!mounted) return; if (score >= 0.60) { showSuccessSnackBar( context, 'Face matched! Similarity: ${(score * 100).toStringAsFixed(1)}%', ); } else { showWarningSnackBar( context, 'Face did not match. Similarity: ${(score * 100).toStringAsFixed(1)}%', ); } } catch (e) { if (mounted) showErrorSnackBar(context, 'Recognition test failed: $e'); } } Future _onSaveDetails() async { if (!_detailsKey.currentState!.validate()) return; final id = ref.read(currentUserIdProvider); if (id == null) return; setState(() => _savingDetails = true); try { await ref .read(profileControllerProvider) .updateFullName( userId: id, fullName: _fullNameController.text.trim(), ); if (!mounted) return; showSuccessSnackBar(context, 'Profile updated.'); // Refresh providers so other UI picks up the change immediately ref.invalidate(currentProfileProvider); ref.invalidate(profilesProvider); } catch (e) { if (!mounted) return; showErrorSnackBar(context, 'Update failed: $e'); } finally { if (mounted) setState(() => _savingDetails = false); } } Future _onChangePassword() async { if (!_passwordKey.currentState!.validate()) return; final pw = _newPasswordController.text; if (pw.isEmpty) { // nothing to do return; } setState(() => _changingPassword = true); try { await ref.read(profileControllerProvider).updatePassword(pw); _newPasswordController.clear(); _confirmPasswordController.clear(); if (!mounted) return; showSuccessSnackBar(context, 'Password updated.'); } catch (e) { if (!mounted) return; showErrorSnackBar(context, 'Password update failed: $e'); } finally { if (mounted) setState(() => _changingPassword = false); } } Future _onSaveOffices() async { final id = ref.read(currentUserIdProvider); if (id == null) return; setState(() => _savingOffices = true); try { final assignments = ref.read(userOfficesProvider).valueOrNull ?? []; final assigned = assignments .where((a) => a.userId == id) .map((a) => a.officeId) .toSet(); final selected = _selectedOfficeIds.toSet(); final toAdd = selected.difference(assigned); final toRemove = assigned.difference(selected); final ctrl = ref.read(userOfficesControllerProvider); for (final officeId in toAdd) { await ctrl.assignUserOffice(userId: id, officeId: officeId); } for (final officeId in toRemove) { await ctrl.removeUserOffice(userId: id, officeId: officeId); } if (!mounted) return; showSuccessSnackBar(context, 'Offices updated.'); ref.invalidate(userOfficesProvider); } catch (e) { if (!mounted) return; showErrorSnackBar(context, 'Failed to save offices: $e'); } finally { if (mounted) setState(() => _savingOffices = false); } } }