feature(qrcode): reboot qrcode reader

This commit is contained in:
Matthew Raymer
2025-04-21 10:13:12 +00:00
parent 6b38b1a347
commit 2055097cf2
11 changed files with 1676 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
<!-- QRScannerDialog.vue -->
<template>
<div
v-if="visible && !isNativePlatform"
class="dialog-overlay z-[60] fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"
>
<div
class="dialog relative bg-white rounded-lg shadow-xl max-w-lg w-full mx-4"
>
<!-- Header -->
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Scan QR Code</h3>
<button
class="absolute top-4 right-4 text-gray-400 hover:text-gray-500"
aria-label="Close dialog"
@click="close"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Scanner -->
<div class="p-4">
<div v-if="useQRReader && !isNativePlatform" class="relative aspect-square">
<qrcode-stream
:camera="options?.camera === 'front' ? 'user' : 'environment'"
@decode="onDecode"
@init="onInit"
/>
<div
class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"
></div>
</div>
<div v-else class="text-center py-8">
<p class="text-gray-500">
{{ isNativePlatform ? 'Using native camera scanner...' : 'QR code scanning is not supported in this browser.' }}
</p>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t border-gray-200">
<p v-if="error" class="text-red-500 text-sm mb-4">{{ error }}</p>
<div class="flex justify-end">
<button
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
@click="close"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { QRScannerOptions } from "@/services/QRScanner/types";
import { logger } from "@/utils/logger";
import { Capacitor } from "@capacitor/core";
@Component({
components: {
QrcodeStream,
},
})
export default class QRScannerDialog extends Vue {
@Prop({ type: Function, required: true }) onScan!: (result: string) => void;
@Prop({ type: Function }) onError?: (error: Error) => void;
@Prop({ type: Object }) options?: QRScannerOptions;
visible = true;
error: string | null = null;
useQRReader = __USE_QR_READER__;
isNativePlatform = Capacitor.isNativePlatform() || __IS_MOBILE__ || Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios';
created() {
logger.log('QRScannerDialog platform detection:', {
capacitorNative: Capacitor.isNativePlatform(),
isMobile: __IS_MOBILE__,
platform: Capacitor.getPlatform(),
useQRReader: this.useQRReader,
isNativePlatform: this.isNativePlatform
});
// If on native platform, close immediately and don't initialize web scanner
if (this.isNativePlatform) {
logger.log('Closing QR dialog on native platform');
this.$nextTick(() => this.close());
}
}
async onInit(promise: Promise<void>): Promise<void> {
// Don't initialize on mobile platforms
if (this.isNativePlatform) {
logger.log('Skipping web scanner initialization on native platform');
return;
}
try {
await promise;
this.error = null;
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
if (this.onError) {
this.onError(error instanceof Error ? error : new Error(String(error)));
}
logger.error("Error initializing QR scanner:", error);
}
}
onDecode(result: string): void {
try {
this.onScan(result);
this.close();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
if (this.onError) {
this.onError(error instanceof Error ? error : new Error(String(error)));
}
logger.error("Error handling QR scan result:", error);
}
}
async close(): Promise<void> {
this.visible = false;
await this.$nextTick();
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el);
}
}
}
</script>
<style scoped>
.dialog-overlay {
backdrop-filter: blur(4px);
}
.qrcode-stream {
width: 100%;
height: 100%;
}
</style>

4
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __USE_QR_READER__: boolean;
declare const __IS_MOBILE__: boolean;

View File

@@ -0,0 +1,118 @@
import {
BarcodeScanner,
BarcodeFormat,
StartScanOptions,
LensFacing,
} from "@capacitor-mlkit/barcode-scanning";
import { QRScannerService, ScanListener, QRScannerOptions } from "./types";
import { logger } from "@/utils/logger";
export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
async checkPermissions(): Promise<boolean> {
try {
const { camera } = await BarcodeScanner.checkPermissions();
return camera === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
return false;
}
}
async requestPermissions(): Promise<boolean> {
try {
// First check if we already have permissions
if (await this.checkPermissions()) {
return true;
}
// Request permissions if we don't have them
const { camera } = await BarcodeScanner.requestPermissions();
return camera === "granted";
} catch (error) {
logger.error("Error requesting camera permissions:", error);
return false;
}
}
async isSupported(): Promise<boolean> {
try {
const { supported } = await BarcodeScanner.isSupported();
return supported;
} catch (error) {
logger.error("Error checking scanner support:", error);
return false;
}
}
async startScan(options?: QRScannerOptions): Promise<void> {
if (this.isScanning) {
return;
}
try {
// Ensure we have permissions before starting
logger.log('Checking camera permissions...');
if (!(await this.checkPermissions())) {
logger.log('Requesting camera permissions...');
const granted = await this.requestPermissions();
if (!granted) {
throw new Error("Camera permission denied");
}
}
// Check if scanning is supported
logger.log('Checking scanner support...');
if (!(await this.isSupported())) {
throw new Error("QR scanning not supported on this device");
}
logger.log('Starting MLKit scanner...');
this.isScanning = true;
const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
lensFacing:
options?.camera === "front" ? LensFacing.Front : LensFacing.Back,
};
logger.log('Scanner options:', scanOptions);
const result = await BarcodeScanner.scan(scanOptions);
logger.log('Scan result:', result);
if (result.barcodes.length > 0) {
this.scanListener?.onScan(result.barcodes[0].rawValue);
}
} catch (error) {
logger.error("Error during QR scan:", error);
this.scanListener?.onError?.(error as Error);
} finally {
this.isScanning = false;
}
}
async stopScan(): Promise<void> {
if (!this.isScanning) {
return;
}
try {
await BarcodeScanner.stopScan();
this.isScanning = false;
} catch (error) {
logger.error("Error stopping QR scan:", error);
this.scanListener?.onError?.(error as Error);
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener;
}
async cleanup(): Promise<void> {
await this.stopScan();
this.scanListener = null;
}
}

View File

@@ -0,0 +1,63 @@
import { Capacitor } from "@capacitor/core";
import { QRScannerService } from "./types";
import { CapacitorQRScanner } from "./CapacitorQRScanner";
import { WebDialogQRScanner } from "./WebDialogQRScanner";
import { logger } from "@/utils/logger";
/**
* Factory class for creating QR scanner instances based on platform
*/
export class QRScannerFactory {
private static instance: QRScannerService | null = null;
private static isNativePlatform(): boolean {
const capacitorNative = Capacitor.isNativePlatform();
const isMobile = __IS_MOBILE__;
const platform = Capacitor.getPlatform();
logger.log('Platform detection:', {
capacitorNative,
isMobile,
platform,
userAgent: navigator.userAgent
});
// Force native scanner on Android/iOS
if (platform === 'android' || platform === 'ios') {
return true;
}
return capacitorNative || isMobile;
}
/**
* Get a QR scanner instance appropriate for the current platform
*/
static getInstance(): QRScannerService {
if (!this.instance) {
const isNative = this.isNativePlatform();
logger.log(`Creating QR scanner for platform: ${isNative ? 'native' : 'web'}`);
if (isNative) {
logger.log('Using native MLKit scanner');
this.instance = new CapacitorQRScanner();
} else if (__USE_QR_READER__) {
logger.log('Using web QR scanner');
this.instance = new WebDialogQRScanner();
} else {
throw new Error("No QR scanner implementation available for this platform");
}
}
return this.instance!; // We know it's not null here
}
/**
* Clean up the current scanner instance
*/
static async cleanup(): Promise<void> {
if (this.instance) {
await this.instance.cleanup();
this.instance = null;
}
}
}

View File

@@ -0,0 +1,108 @@
import { createApp, App } from "vue";
import { QRScannerService, ScanListener, QRScannerOptions } from "./types";
import QRScannerDialog from "@/components/QRScanner/QRScannerDialog.vue";
import { logger } from "@/utils/logger";
export class WebDialogQRScanner implements QRScannerService {
private dialogInstance: App | null = null;
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null;
private scanListener: ScanListener | null = null;
private isScanning = false;
constructor(private options?: QRScannerOptions) {}
async checkPermissions(): Promise<boolean> {
try {
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
return permissions.state === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
return false;
}
}
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((track) => track.stop());
return true;
} catch (error) {
logger.error("Error requesting camera permissions:", error);
return false;
}
}
async isSupported(): Promise<boolean> {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
async startScan(): Promise<void> {
if (this.isScanning) {
return;
}
try {
this.isScanning = true;
// Create and mount dialog component
const container = document.createElement("div");
document.body.appendChild(container);
this.dialogInstance = createApp(QRScannerDialog, {
onScan: (result: string) => {
if (this.scanListener) {
this.scanListener.onScan(result);
}
},
onError: (error: Error) => {
if (this.scanListener?.onError) {
this.scanListener.onError(error);
}
},
options: this.options,
});
this.dialogComponent = this.dialogInstance.mount(container).$refs
.dialog as InstanceType<typeof QRScannerDialog>;
} catch (error) {
this.isScanning = false;
if (this.scanListener?.onError) {
this.scanListener.onError(
error instanceof Error ? error : new Error(String(error)),
);
}
logger.error("Error starting scan:", error);
}
}
async stopScan(): Promise<void> {
if (!this.isScanning) {
return;
}
try {
if (this.dialogComponent) {
await this.dialogComponent.close();
}
if (this.dialogInstance) {
this.dialogInstance.unmount();
}
this.isScanning = false;
} catch (error) {
logger.error("Error stopping scan:", error);
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener;
}
async cleanup(): Promise<void> {
await this.stopScan();
this.dialogComponent = null;
this.dialogInstance = null;
this.scanListener = null;
}
}

View File

@@ -0,0 +1,49 @@
// QR Scanner Service Types
/**
* Listener interface for QR code scan events
*/
export interface ScanListener {
/** Called when a QR code is successfully scanned */
onScan: (result: string) => void;
/** Called when an error occurs during scanning */
onError?: (error: Error) => void;
}
/**
* Options for configuring the QR scanner
*/
export interface QRScannerOptions {
/** Camera to use ('front' or 'back') */
camera?: "front" | "back";
/** Whether to show a preview of the camera feed */
showPreview?: boolean;
/** Whether to play a sound on successful scan */
playSound?: boolean;
}
/**
* Interface for QR scanner service implementations
*/
export interface QRScannerService {
/** Check if camera permissions are granted */
checkPermissions(): Promise<boolean>;
/** Request camera permissions from the user */
requestPermissions(): Promise<boolean>;
/** Check if QR scanning is supported on this device */
isSupported(): Promise<boolean>;
/** Start scanning for QR codes */
startScan(options?: QRScannerOptions): Promise<void>;
/** Stop scanning for QR codes */
stopScan(): Promise<void>;
/** Add a listener for scan events */
addListener(listener: ScanListener): void;
/** Clean up scanner resources */
cleanup(): Promise<void>;
}