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)