In today's mobile-first world, QR code scanning has become an essential feature for many applications, particularly in healthcare settings. In this post, I'll walk through how I built a secure QR scanner component for a hospital staff application that includes geofencing capabilities to ensure scanning only occurs within authorized locations.
The Challenge
The requirements for this scanner were specific:
- Only work when staff are physically within the hospital premises
- Provide both automatic QR scanning and manual UHID entry
- Include flash capability for low-light conditions
- Validate scanned data against backend systems
- Maintain security throughout the process
Solution Architecture
- The component uses several key technologies:
- Vue 3 with Composition API for reactive UI
- qr-scanner library for efficient QR code detection
- Google Geolocation API for accurate positioning
- Vuetify for Material Design UI components
- Pinia for state management
1. Geofencing Implementation
async checkGeofence() { this.isLocating = true; this.locationError = 'Checking your location, please wait...'; let userCoords = await this.getAccurateLocation(); // Fallback to browser geolocation if Google API fails if (!userCoords && navigator.geolocation) { userCoords = await this.getBrowserLocation(); } if (!userCoords) { this.handleLocationError(); return; } const distance = this.calculateDistance(this.hospitalLocation, userCoords); if (distance <= this.allowedRadius) { this.enableScanner(); } else { this.disableScanner(distance); } this.isLocating = false; }
The geofencing uses the Haversine formula to calculate distance between coordinates:
getDistance(coords1, coords2) { const R = 6371e3; // Earth radius in meters const φ1 = (coords1.lat * Math.PI) / 180; const φ2 = (coords2.lat * Math.PI) / 180; const Δφ = ((coords2.lat - coords1.lat) * Math.PI) / 180; const Δλ = ((coords2.lon - coords1.lon) * Math.PI) / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }
2. QR Scanner Implementation
The scanner initializes with optimal settings for healthcare use:
initScanner() { this.scannerRef = new QrScanner( this.$refs.videoRef, (result) => { const decodedData = this.decodeBase64(result.data); if (decodedData) { this.processScanResult(decodedData); } }, { highlightScanRegion: true, preferredCamera: 'environment', maxScansPerSecond: 1, returnDetailedScanResult: true } ); this.scannerRef.start().catch(this.handleCameraError); }
3. Manual Entry Fallback
For cases where QR scanning isn't possible, we provide a manual entry option:
<v-fade-transition> <div v-if="showManualEntry" class="w-100 mt-2 d-flex align-center"> <v-text-field v-model="manualUhid" label="Enter UHID" variant="outlined" class="w-100" rounded="xl" density="compact" hide-details /> <v-btn @click="submitManualUhid" color="green" size="small" icon="mdi-check" class="ml-3" rounded="xl" elevation="2" /> </div> </v-fade-transition>
Security Considerations
- Location Verification: Ensures scanning only occurs within hospital premises
- Base64 Decoding: Safely decodes and parses QR content
- Camera Permissions: Handles permission errors gracefully
- Data Validation: All scanned data is validated against backend systems ** Challenges and Solutions** Challenge: **Accurate location detection on mobile devices **Solution: Implemented a hybrid approach using Google's Geolocation API with browser geolocation fallback
Challenge: Camera performance on low-end devices
Solution: Limited scan rate to 1 scan/second and optimized video settings
Challenge: User experience in varying light conditions
Solution: Added flash toggle and clear visual feedback
Complete code--->
<template> <div> <div v-if="locationError" class="pa-4 text-center"> <v-alert type="error" variant="tonal" border="start" prominent> {{ locationError }} </v-alert> </div> <div v-if="isWithinRange"> <div class="scanner-wrapper"> <video ref="videoRef" class="scanner-video rounded-xl elevation-3" :style="{ border: scanActive ? '2px solid #4CAF50' : '2px solid #757575' }" ></video> <div class="scanner-frame"> <div class="scan-line" :class="{ 'scan-animation': scanActive }"></div> </div> </div> <div class="d-flex flex-column align-center pa-4 gap-4"> <div class="d-flex justify-center align-center"> <v-btn @click="toggleManualEntry" :color="showManualEntry ? 'green' : 'secondary'" rounded="xl" elevation="2" :text="showManualEntry ? 'Close Manual Entry' : 'Enter UHID Manually'" /> <v-btn v-if="cameraAvailable" @click="toggleFlash" :color="flashOn ? 'yellow' : 'grey-lighten-1'" :icon="flashOn ? 'mdi-flashlight-off' : 'mdi-flashlight'" class="ml-3" size="small" rounded="xl" elevation="2" /> <v-btn color="primary" @click="checkGeofence" rounded="xl" class="ml-2" elevation="2" prepend-icon="mdi-crosshairs-gps" text=" Current Location" /> </div> <v-fade-transition> <div v-if="showManualEntry" class="w-100 mt-2 d-flex align-center"> <v-text-field v-model="manualUhid" label="Enter UHID" variant="outlined" class="w-100" rounded="xl" density="compact" hide-details /> <v-btn @click="submitManualUhid" color="green" size="small" icon="mdi-check" class="ml-3" rounded="xl" elevation="2" /> </div> </v-fade-transition> </div> </div> </div> </template> <script> import QrScanner from 'qr-scanner'; import { useStaffStore } from '@/store/staffStore.js'; import axios from 'axios'; export default { name: 'OBQRScanner', data() { return { scanActive: true, flashOn: false, scanResult: false, cameraAvailable: false, scannerRef: null, showManualEntry: false, manualUhid: null, hco_id: null, user_id: null, uhid: null, location_id: null, isLocating: false, visit_id: null, key: null, appointment_id: null, isWithinRange: false, locationError: 'Checking your location, please wait...', hospitalLocation: { lat: 25.9278416, lon: 83.6141056 }, allowedRadius: 300 }; }, mounted() { this.checkGeofence(); }, beforeUnmount() { this.stopScanner(); }, computed: { user_type: () => useStaffStore().user_type || '' }, methods: { async getAccurateLocation() { const GOOGLE_API_KEY = import.meta.env.VITE_GOOGLE_GEOLOCATION_API_KEY; try { const response = await axios.post(`https://www.googleapis.com/geolocation/v1/geolocate?key=${GOOGLE_API_KEY}`, { considerIp: 'true' }); if (response.data && response.data.location) { const userCoords = { lat: response.data.location.lat, lon: response.data.location.lng, accuracy: response.data.accuracy }; console.log(' Google API location:', userCoords); return userCoords; } else { throw new Error('Failed to get location from Google API.'); } } catch (error) { console.error('Google Geolocation API error:', error); return null; } }, async checkGeofence() { this.isLocating = true; this.locationError = 'Checking your location, please wait...'; let userCoords = await this.getAccurateLocation(); // fallback if (!userCoords && navigator.geolocation) { try { userCoords = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( (pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), (err) => reject(err), { enableHighAccuracy: true, timeout: 10000 } ); }); } catch (e) { this.locationError = 'Unable to determine your location.'; this.isLocating = false; return; } } if (!userCoords) { this.locationError = 'Location detection failed.'; this.isLocating = false; return; } const distance = this.getDistance(this.hospitalLocation, userCoords); if (distance <= this.allowedRadius) { this.isWithinRange = true; this.locationError = null; this.$nextTick(() => { this.initScanner(); }); } else { this.isWithinRange = false; this.locationError = `Scanner Disabled: You are approximately ${distance.toFixed(0)} meters away and need to be within ${ this.allowedRadius } meters of the hospital.`; } this.isLocating = false; }, getDistance(coords1, coords2) { const R = 6371e3; const φ1 = (coords1.lat * Math.PI) / 180; const φ2 = (coords2.lat * Math.PI) / 180; const Δφ = ((coords2.lat - coords1.lat) * Math.PI) / 180; const Δλ = ((coords2.lon - coords1.lon) * Math.PI) / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }, initScanner() { const videoRef = this.$refs.videoRef; if (!videoRef) { console.error('Scanner init failed: Video element not found.'); return; } this.scannerRef = new QrScanner( videoRef, (result) => { const decodedData = this.decodeBase64(result.data); if (decodedData) { this.parseDecodedData(decodedData); this.validatePatient(); this.scanResult = true; this.scanActive = false; this.scannerRef.stop(); } }, { highlightScanRegion: true, highlightCodeOutline: true, preferredCamera: 'environment', maxScansPerSecond: 1, returnDetailedScanResult: true } ); this.scannerRef .start() .then(() => { this.cameraAvailable = true; }) .catch((err) => { console.error('Camera error:', err); if (err === 'NotAllowedError') { this.locationError = 'Camera access was denied. Please enable camera permissions in your browser settings.'; } else { this.locationError = `Could not start camera. Error: ${err.name || err}`; } }); }, stopScanner() { if (this.scannerRef) { this.scannerRef.stop(); this.scannerRef.destroy(); this.scannerRef = null; } }, toggleManualEntry() { this.showManualEntry = !this.showManualEntry; if (!this.showManualEntry) { this.manualUhid = null; } }, submitManualUhid() { if (this.manualUhid) { this.uhid = this.manualUhid; this.validatePatient(); } }, toggleFlash() { if (this.cameraAvailable) { this.scannerRef.toggleFlash().then(() => { this.flashOn = !this.flashOn; }); } }, decodeBase64(base64Data) { try { const decoded = atob(base64Data); return JSON.parse(decoded); } catch (error) { console.error('Failed to decode base64 or parse JSON:', error); return null; } }, parseDecodedData(decodedData) { this.hco_id = decodedData.hco_id; this.user_id = decodedData.user_id; this.uhid = decodedData.uhid; this.location_id = decodedData.location_id; this.visit_id = decodedData.visit_id; this.key = decodedData.key; this.appointment_id = decodedData.appointment_id || null; }, async validatePatient() { try { const data = { hco_id: this.hco_id, user_id: this.user_id, uhid: this.uhid, location_id: 1, visit_id: this.visit_id, key: this.key, event: this.user_type, manualUhid: this.manualUhid, appointment_id: this.appointment_id }; const response = await axios.post('/staff/QrScanner.html?action=scanQR', data); if (response.data) { this.stopScanner(); this.$router.go(-1); } } catch (error) { console.error('Error validating patient:', error); } } } }; </script> <style scoped> .qr-scanner-container { max-width: 100%; padding: 16px; height: 100vh; display: flex; flex-direction: column; } .scanner-wrapper { position: relative; width: 100%; margin: 0 auto; max-width: 400px; } .scanner-video { width: 100%; height: 400px; display: block; transition: all 0.3s ease; } .scanner-frame { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } .scan-line { position: absolute; top: 50%; left: 30%; width: 39%; height: 2px; background: rgba(76, 175, 80, 0.8); box-shadow: 0 0 10px rgba(76, 175, 80, 0.8); z-index: 1; } .scan-animation { animation: scan 2s infinite linear; } @keyframes scan { 0% { top: 10%; } 100% { top: 90%; } } </style>
Top comments (0)