forked from trent_larson/crowd-funder-for-time-pwa
- Remove duplicate NOTIFY_INVITE_MISSING and NOTIFY_INVITE_PROCESSING_ERROR exports - Update InviteOneAcceptView.vue to use correct NOTIFY_INVITE_TRUNCATED_DATA constant - Migrate ContactsView to PlatformServiceMixin and extract into modular sub-components - Resolves TypeScript compilation errors preventing web build
722 lines
16 KiB
Vue
722 lines
16 KiB
Vue
<template>
|
|
<div class="qr-scanner-component">
|
|
<h2>QR Code Scanner</h2>
|
|
|
|
<!-- Camera controls -->
|
|
<div class="camera-controls">
|
|
<button :disabled="isScanning || !hasCamera" @click="startScanning">
|
|
{{ isScanning ? "Scanning..." : "Start Scanning" }}
|
|
</button>
|
|
<button :disabled="!isScanning" @click="stopScanning">
|
|
Stop Scanning
|
|
</button>
|
|
<button
|
|
:disabled="!isScanning || cameras.length <= 1"
|
|
@click="switchCamera"
|
|
>
|
|
Switch Camera
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Camera status -->
|
|
<div class="camera-status">
|
|
<div v-if="!hasCamera" class="status-error">
|
|
<p>Camera not available</p>
|
|
<p class="status-detail">
|
|
This device doesn't have a camera or camera access is denied.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else-if="!isScanning" class="status-info">
|
|
<p>Camera ready</p>
|
|
<p class="status-detail">
|
|
Click "Start Scanning" to begin QR code detection.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="status-scanning">
|
|
<p>Scanning for QR codes...</p>
|
|
<p class="status-detail">Point camera at a QR code to scan.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Camera view -->
|
|
<div v-if="isScanning && hasCamera" class="camera-container">
|
|
<video
|
|
ref="videoElement"
|
|
class="camera-video"
|
|
autoplay
|
|
playsinline
|
|
muted
|
|
></video>
|
|
|
|
<!-- Scanning overlay -->
|
|
<div class="scanning-overlay">
|
|
<div class="scan-frame"></div>
|
|
<div class="scan-line"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scan results -->
|
|
<div v-if="scanResults.length > 0" class="scan-results">
|
|
<h3>Scan Results ({{ scanResults.length }})</h3>
|
|
|
|
<div class="results-list">
|
|
<div
|
|
v-for="(result, index) in scanResults"
|
|
:key="index"
|
|
class="result-item"
|
|
>
|
|
<div class="result-header">
|
|
<span class="result-number">#{{ index + 1 }}</span>
|
|
<span class="result-time">{{ formatTime(result.timestamp) }}</span>
|
|
</div>
|
|
<div class="result-content">
|
|
<div class="qr-data"><strong>Data:</strong> {{ result.data }}</div>
|
|
<div class="qr-format">
|
|
<strong>Format:</strong> {{ result.format }}
|
|
</div>
|
|
</div>
|
|
<div class="result-actions">
|
|
<button class="copy-btn" @click="copyToClipboard(result.data)">
|
|
Copy
|
|
</button>
|
|
<button class="remove-btn" @click="removeResult(index)">
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="results-actions">
|
|
<button class="clear-btn" @click="clearResults">
|
|
Clear All Results
|
|
</button>
|
|
<button class="export-btn" @click="exportResults">
|
|
Export Results
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings panel -->
|
|
<div class="settings-panel">
|
|
<h3>Scanner Settings</h3>
|
|
|
|
<div class="setting-group">
|
|
<label>
|
|
<input v-model="settings.continuousScanning" type="checkbox" />
|
|
Continuous Scanning
|
|
</label>
|
|
<p class="setting-description">
|
|
Automatically scan multiple QR codes without stopping
|
|
</p>
|
|
</div>
|
|
|
|
<div class="setting-group">
|
|
<label>
|
|
<input v-model="settings.audioFeedback" type="checkbox" />
|
|
Audio Feedback
|
|
</label>
|
|
<p class="setting-description">Play sound when QR code is detected</p>
|
|
</div>
|
|
|
|
<div class="setting-group">
|
|
<label>
|
|
<input v-model="settings.vibrateOnScan" type="checkbox" />
|
|
Vibration Feedback
|
|
</label>
|
|
<p class="setting-description">
|
|
Vibrate device when QR code is detected
|
|
</p>
|
|
</div>
|
|
|
|
<div class="setting-group">
|
|
<label>Scan Interval (ms):</label>
|
|
<input
|
|
v-model.number="settings.scanInterval"
|
|
type="number"
|
|
min="100"
|
|
max="5000"
|
|
step="100"
|
|
/>
|
|
<p class="setting-description">
|
|
Time between scans (lower = faster, higher = more accurate)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Component, Vue, Emit } from "vue-facing-decorator";
|
|
|
|
interface ScanResult {
|
|
data: string;
|
|
format: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
interface ScannerSettings {
|
|
continuousScanning: boolean;
|
|
audioFeedback: boolean;
|
|
vibrateOnScan: boolean;
|
|
scanInterval: number;
|
|
}
|
|
|
|
/**
|
|
* QR Scanner Component
|
|
*
|
|
* Demonstrates lazy loading for camera-dependent features.
|
|
* This component would benefit from lazy loading as it requires
|
|
* camera permissions and heavy camera processing libraries.
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
@Component({
|
|
name: "QRScannerComponent",
|
|
})
|
|
export default class QRScannerComponent extends Vue {
|
|
// Component state
|
|
isScanning = false;
|
|
hasCamera = false;
|
|
cameras: MediaDeviceInfo[] = [];
|
|
currentCameraIndex = 0;
|
|
|
|
// Video element reference
|
|
videoElement: HTMLVideoElement | null = null;
|
|
|
|
// Scan results
|
|
scanResults: ScanResult[] = [];
|
|
|
|
// Scanner settings
|
|
settings: ScannerSettings = {
|
|
continuousScanning: true,
|
|
audioFeedback: true,
|
|
vibrateOnScan: true,
|
|
scanInterval: 500,
|
|
};
|
|
|
|
// Internal state
|
|
private stream: MediaStream | null = null;
|
|
private scanInterval: number | null = null;
|
|
private lastScanTime = 0;
|
|
|
|
// Lifecycle hooks
|
|
async mounted(): Promise<void> {
|
|
console.log("[QRScannerComponent] Component mounted");
|
|
await this.initializeCamera();
|
|
}
|
|
|
|
beforeUnmount(): void {
|
|
this.stopScanning();
|
|
console.log("[QRScannerComponent] Component unmounting");
|
|
}
|
|
|
|
// Methods
|
|
async initializeCamera(): Promise<void> {
|
|
try {
|
|
// Check if camera is available
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
this.cameras = devices.filter((device) => device.kind === "videoinput");
|
|
this.hasCamera = this.cameras.length > 0;
|
|
|
|
if (this.hasCamera) {
|
|
console.log(
|
|
"[QRScannerComponent] Camera available:",
|
|
this.cameras.length,
|
|
"devices",
|
|
);
|
|
} else {
|
|
console.warn("[QRScannerComponent] No camera devices found");
|
|
}
|
|
} catch (error) {
|
|
console.error("[QRScannerComponent] Camera initialization error:", error);
|
|
this.hasCamera = false;
|
|
}
|
|
}
|
|
|
|
async startScanning(): Promise<void> {
|
|
if (!this.hasCamera || this.isScanning) return;
|
|
|
|
try {
|
|
console.log("[QRScannerComponent] Starting QR scanning...");
|
|
|
|
// Get camera stream
|
|
const constraints = {
|
|
video: {
|
|
deviceId: this.cameras[this.currentCameraIndex]?.deviceId,
|
|
},
|
|
};
|
|
|
|
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
|
|
// Set up video element
|
|
this.videoElement = this.$refs.videoElement as HTMLVideoElement;
|
|
if (this.videoElement) {
|
|
this.videoElement.srcObject = this.stream;
|
|
await this.videoElement.play();
|
|
}
|
|
|
|
this.isScanning = true;
|
|
|
|
// Start QR code detection
|
|
this.startQRDetection();
|
|
|
|
console.log("[QRScannerComponent] QR scanning started");
|
|
} catch (error) {
|
|
console.error("[QRScannerComponent] Failed to start scanning:", error);
|
|
this.hasCamera = false;
|
|
}
|
|
}
|
|
|
|
stopScanning(): void {
|
|
if (!this.isScanning) return;
|
|
|
|
console.log("[QRScannerComponent] Stopping QR scanning...");
|
|
|
|
// Stop QR detection
|
|
this.stopQRDetection();
|
|
|
|
// Stop camera stream
|
|
if (this.stream) {
|
|
this.stream.getTracks().forEach((track) => track.stop());
|
|
this.stream = null;
|
|
}
|
|
|
|
// Clear video element
|
|
if (this.videoElement) {
|
|
this.videoElement.srcObject = null;
|
|
this.videoElement = null;
|
|
}
|
|
|
|
this.isScanning = false;
|
|
console.log("[QRScannerComponent] QR scanning stopped");
|
|
}
|
|
|
|
async switchCamera(): Promise<void> {
|
|
if (this.cameras.length <= 1) return;
|
|
|
|
// Stop current scanning
|
|
this.stopScanning();
|
|
|
|
// Switch to next camera
|
|
this.currentCameraIndex =
|
|
(this.currentCameraIndex + 1) % this.cameras.length;
|
|
|
|
// Restart scanning with new camera
|
|
await this.startScanning();
|
|
|
|
console.log(
|
|
"[QRScannerComponent] Switched to camera:",
|
|
this.currentCameraIndex,
|
|
);
|
|
}
|
|
|
|
private startQRDetection(): void {
|
|
if (!this.settings.continuousScanning) return;
|
|
|
|
this.scanInterval = window.setInterval(() => {
|
|
this.detectQRCode();
|
|
}, this.settings.scanInterval);
|
|
}
|
|
|
|
private stopQRDetection(): void {
|
|
if (this.scanInterval) {
|
|
clearInterval(this.scanInterval);
|
|
this.scanInterval = null;
|
|
}
|
|
}
|
|
|
|
private async detectQRCode(): Promise<void> {
|
|
if (!this.videoElement || !this.isScanning) return;
|
|
|
|
const now = Date.now();
|
|
if (now - this.lastScanTime < this.settings.scanInterval) return;
|
|
|
|
try {
|
|
// Simulate QR code detection
|
|
// In a real implementation, you would use a QR code library like jsQR
|
|
const detectedQR = await this.simulateQRDetection();
|
|
|
|
if (detectedQR) {
|
|
this.addScanResult(detectedQR);
|
|
this.lastScanTime = now;
|
|
}
|
|
} catch (error) {
|
|
console.error("[QRScannerComponent] QR detection error:", error);
|
|
}
|
|
}
|
|
|
|
private async simulateQRDetection(): Promise<ScanResult | null> {
|
|
// Simulate QR code detection with random chance
|
|
if (Math.random() < 0.1) {
|
|
// 10% chance of detection
|
|
const sampleData = [
|
|
"https://example.com/qr1",
|
|
"WIFI:S:MyNetwork;T:WPA;P:password123;;",
|
|
"BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nTEL:+1234567890\nEND:VCARD",
|
|
"otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
|
];
|
|
|
|
const formats = ["URL", "WiFi", "vCard", "TOTP"];
|
|
const randomIndex = Math.floor(Math.random() * sampleData.length);
|
|
|
|
return {
|
|
data: sampleData[randomIndex],
|
|
format: formats[randomIndex],
|
|
timestamp: new Date(),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private addScanResult(result: ScanResult): void {
|
|
// Check for duplicates
|
|
const isDuplicate = this.scanResults.some(
|
|
(existing) => existing.data === result.data,
|
|
);
|
|
|
|
if (!isDuplicate) {
|
|
this.scanResults.unshift(result);
|
|
|
|
// Provide feedback
|
|
this.provideFeedback();
|
|
|
|
// Emit event
|
|
this.$emit("qr-detected", result.data);
|
|
|
|
console.log("[QRScannerComponent] QR code detected:", result.data);
|
|
}
|
|
}
|
|
|
|
private provideFeedback(): void {
|
|
// Audio feedback
|
|
if (this.settings.audioFeedback) {
|
|
this.playBeepSound();
|
|
}
|
|
|
|
// Vibration feedback
|
|
if (this.settings.vibrateOnScan && "vibrate" in navigator) {
|
|
navigator.vibrate(100);
|
|
}
|
|
}
|
|
|
|
private playBeepSound(): void {
|
|
// Create a simple beep sound
|
|
const audioContext = new (window.AudioContext ||
|
|
(window as any).webkitAudioContext)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
|
oscillator.type = "sine";
|
|
|
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(
|
|
0.01,
|
|
audioContext.currentTime + 0.1,
|
|
);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.1);
|
|
}
|
|
|
|
copyToClipboard(text: string): void {
|
|
navigator.clipboard
|
|
.writeText(text)
|
|
.then(() => {
|
|
console.log("[QRScannerComponent] Copied to clipboard:", text);
|
|
})
|
|
.catch((error) => {
|
|
console.error("[QRScannerComponent] Failed to copy:", error);
|
|
});
|
|
}
|
|
|
|
removeResult(index: number): void {
|
|
this.scanResults.splice(index, 1);
|
|
}
|
|
|
|
clearResults(): void {
|
|
this.scanResults = [];
|
|
console.log("[QRScannerComponent] Results cleared");
|
|
}
|
|
|
|
exportResults(): void {
|
|
const data = JSON.stringify(this.scanResults, null, 2);
|
|
const blob = new Blob([data], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `qr-scan-results-${new Date().toISOString().split("T")[0]}.json`;
|
|
a.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
console.log("[QRScannerComponent] Results exported");
|
|
}
|
|
|
|
formatTime(date: Date): string {
|
|
return date.toLocaleTimeString();
|
|
}
|
|
|
|
// Event emitters
|
|
@Emit("qr-detected")
|
|
emitQRDetected(data: string): string {
|
|
return data;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.qr-scanner-component {
|
|
padding: 20px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.camera-controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.camera-controls button {
|
|
padding: 8px 16px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.camera-controls button:hover:not(:disabled) {
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.camera-controls button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.camera-status {
|
|
margin-bottom: 20px;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.status-error {
|
|
background: #f8d7da;
|
|
border: 1px solid #f5c6cb;
|
|
color: #721c24;
|
|
}
|
|
|
|
.status-info {
|
|
background: #d1ecf1;
|
|
border: 1px solid #bee5eb;
|
|
color: #0c5460;
|
|
}
|
|
|
|
.status-scanning {
|
|
background: #d4edda;
|
|
border: 1px solid #c3e6cb;
|
|
color: #155724;
|
|
}
|
|
|
|
.status-detail {
|
|
margin-top: 5px;
|
|
font-size: 0.9em;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.camera-container {
|
|
position: relative;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
height: 300px;
|
|
margin: 20px auto;
|
|
border: 2px solid #ddd;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.camera-video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.scanning-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.scan-frame {
|
|
width: 200px;
|
|
height: 200px;
|
|
border: 2px solid #00ff00;
|
|
border-radius: 8px;
|
|
position: relative;
|
|
}
|
|
|
|
.scan-line {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 2px;
|
|
background: #00ff00;
|
|
animation: scan 2s linear infinite;
|
|
}
|
|
|
|
@keyframes scan {
|
|
0% {
|
|
top: 0;
|
|
}
|
|
100% {
|
|
top: 100%;
|
|
}
|
|
}
|
|
|
|
.scan-results {
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.results-list {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
background: #fff;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.result-item {
|
|
padding: 12px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.result-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.result-number {
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
}
|
|
|
|
.result-time {
|
|
font-size: 0.85em;
|
|
color: #666;
|
|
}
|
|
|
|
.result-content {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.qr-data,
|
|
.qr-format {
|
|
margin-bottom: 5px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.result-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.copy-btn,
|
|
.remove-btn {
|
|
padding: 4px 8px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 3px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.copy-btn:hover {
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.remove-btn:hover {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.results-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.clear-btn,
|
|
.export-btn {
|
|
padding: 8px 16px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.clear-btn:hover {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.export-btn:hover {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
.settings-panel {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: #f8f9fa;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.setting-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.setting-group label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.setting-group input[type="number"] {
|
|
width: 100px;
|
|
padding: 4px 8px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.setting-description {
|
|
font-size: 0.85em;
|
|
color: #666;
|
|
margin-top: 3px;
|
|
margin-left: 24px;
|
|
}
|
|
</style>
|