tasq/lib/widgets/qr_verification_dialog.dart

242 lines
7.5 KiB
Dart

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<bool> 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<bool>(
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<VerificationSession>? _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<void> _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 Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.qr_code_2, color: colors.primary),
const SizedBox(width: 12),
const Expanded(child: Text('Scan with Mobile')),
],
),
const SizedBox(height: 16),
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),
Center(
child: _expired
? _buildExpiredState(theme, colors)
: _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),
Flexible(
child: Text(
'Waiting for mobile verification...',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: 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,
),
),
],
);
}
}