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)