import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../models/verification_session.dart'; import '../providers/profile_provider.dart'; import '../providers/verification_session_provider.dart'; import '../utils/snackbar.dart'; /// Shows a dialog with a QR code for cross-device face verification. /// /// On web, when the camera is unavailable, this dialog is shown so the user /// can scan the QR with the TasQ mobile app to perform liveness detection. /// The dialog listens via Supabase Realtime for the session to complete. /// /// Returns `true` if the verification was completed successfully. Future showQrVerificationDialog({ required BuildContext context, required WidgetRef ref, required String type, String? contextId, }) async { final controller = ref.read(verificationSessionControllerProvider); // Create the verification session final session = await controller.createSession( type: type, contextId: contextId, ); if (!context.mounted) return false; final result = await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _QrVerificationDialog(session: session), ); // If dismissed without completion, expire the session if (result != true) { await controller.expireSession(session.id); } return result == true; } class _QrVerificationDialog extends ConsumerStatefulWidget { const _QrVerificationDialog({required this.session}); final VerificationSession session; @override ConsumerState<_QrVerificationDialog> createState() => _QrVerificationDialogState(); } class _QrVerificationDialogState extends ConsumerState<_QrVerificationDialog> { StreamSubscription? _subscription; bool _completed = false; bool _expired = false; late Timer _expiryTimer; @override void initState() { super.initState(); // Listen for session completion via realtime final controller = ref.read(verificationSessionControllerProvider); _subscription = controller.watchSession(widget.session.id).listen(( session, ) { if (session.isCompleted && !_completed) { _onCompleted(session); } }, onError: (_) {}); // Auto-expire after the session's TTL final remaining = widget.session.expiresAt.difference( DateTime.now().toUtc(), ); _expiryTimer = Timer(remaining.isNegative ? Duration.zero : remaining, () { if (mounted && !_completed) { setState(() => _expired = true); } }); } Future _onCompleted(VerificationSession session) async { _completed = true; if (!mounted) return; // Apply the result (update profile face photo or attendance log) final controller = ref.read(verificationSessionControllerProvider); await controller.applySessionResult(session); // Invalidate profile so UI refreshes ref.invalidate(currentProfileProvider); if (mounted) { Navigator.of(context).pop(true); showSuccessSnackBar( context, session.type == 'enrollment' ? 'Face enrolled successfully via mobile.' : 'Face verification completed via mobile.', ); } } @override void dispose() { _subscription?.cancel(); _expiryTimer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final qrData = 'tasq://verify/${widget.session.id}'; return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), title: Row( children: [ Icon(Icons.qr_code_2, color: colors.primary), const SizedBox(width: 12), const Expanded(child: Text('Scan with Mobile')), ], ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( widget.session.type == 'enrollment' ? 'Open the TasQ app on your phone and scan this QR code to enroll your face with liveness detection.' : 'Open the TasQ app on your phone and scan this QR code to verify your face with liveness detection.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 24), if (_expired) _buildExpiredState(theme, colors) else _buildQrCode(theme, colors, qrData), const SizedBox(height: 16), if (!_expired) ...[ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: colors.primary, ), ), const SizedBox(width: 12), Text( 'Waiting for mobile verification...', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], ), ], ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), ], ); } Widget _buildQrCode(ThemeData theme, ColorScheme colors, String qrData) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: colors.outlineVariant), ), child: QrImageView( data: qrData, version: QrVersions.auto, size: 220, eyeStyle: const QrEyeStyle( eyeShape: QrEyeShape.square, color: Colors.black87, ), dataModuleStyle: const QrDataModuleStyle( dataModuleShape: QrDataModuleShape.square, color: Colors.black87, ), ), ); } Widget _buildExpiredState(ThemeData theme, ColorScheme colors) { return Column( children: [ Icon(Icons.timer_off, size: 48, color: colors.error), const SizedBox(height: 12), Text( 'Session expired', style: theme.textTheme.titleMedium?.copyWith(color: colors.error), ), const SizedBox(height: 4), Text( 'Close this dialog and try again.', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], ); } }