š Overview
In this part, we'll implement:
- Camera permission handling
- QR code scanning with expo-camera
- QR data parsing (format:
eventId:uniqueCode) - Attendance recording via API
- Animated success/error feedback
šÆ QR Code Format
The QR code contains participant registration data:
eventId:uniqueCode
Example: abc123:xyz789
š¦ Step 1: Create Attendance Service
File: services/attendance.ts
import { api } from './api';
interface AttendanceResponse {
success: boolean;
data?: {
id: string;
attendee: {
name: string;
email: string;
};
event: {
title: string;
};
checkedInAt: string;
};
error?: string;
}
export const recordAttendance = async (
eventId: string,
uniqueCode: string
): Promise<AttendanceResponse> => {
try {
const response = await api.post('/attendance', {
eventId,
uniqueCode,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || 'Failed to record attendance',
};
}
};
š± Step 2: Create Scanner Screen
File: app/(tabs)/scanner.tsx
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert, Animated } from 'react-native';
import { CameraView, useCameraPermissions, BarcodeScanningResult } from 'expo-camera';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Camera, CheckCircle, XCircle, RefreshCw } from 'lucide-react-native';
import { recordAttendance } from '@/services/attendance';
type ScanResult = {
type: 'success' | 'error';
title: string;
message: string;
} | null;
export default function ScannerScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [isScanning, setIsScanning] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [result, setResult] = useState<ScanResult>(null);
const [fadeAnim] = useState(new Animated.Value(0));
// Handle barcode scan
const handleBarcodeScanned = async ({ data }: BarcodeScanningResult) => {
if (!isScanning || isProcessing) return;
setIsProcessing(true);
setIsScanning(false);
try {
// Parse QR data: format is "eventId:uniqueCode"
const [eventId, uniqueCode] = data.split(':');
if (!eventId || !uniqueCode) {
showResult('error', 'Invalid QR Code', 'QR code format is invalid');
return;
}
// Record attendance via API
const response = await recordAttendance(eventId, uniqueCode);
if (response.success && response.data) {
showResult(
'success',
'Attendance Recorded!',
`${response.data.attendee.name} checked in to ${response.data.event.title}`
);
} else {
showResult('error', 'Failed', response.error || 'Could not record attendance');
}
} catch (error) {
console.error('Scan error:', error);
showResult('error', 'Error', 'An error occurred while processing');
}
};
// Show result with animation
const showResult = (type: 'success' | 'error', title: string, message: string) => {
setResult({ type, title, message });
setIsProcessing(false);
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.delay(2500),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start(() => {
setResult(null);
setIsScanning(true);
});
};
// Reset scanner
const resetScanner = () => {
setResult(null);
setIsScanning(true);
setIsProcessing(false);
};
// Permission not determined
if (!permission) {
return (
<View className="flex-1 bg-gray-900 items-center justify-center">
<Text className="text-white">Requesting camera permission...</Text>
</View>
);
}
// Permission denied
if (!permission.granted) {
return (
<SafeAreaView className="flex-1 bg-gray-900 items-center justify-center p-6">
<Camera size={64} color="#6b7280" />
<Text className="text-white text-xl font-bold mt-6 text-center">
Camera Access Required
</Text>
<Text className="text-gray-400 text-center mt-2 mb-6">
We need camera access to scan QR codes for attendance
</Text>
<TouchableOpacity
className="bg-orange-500 px-8 py-4 rounded-xl"
onPress={requestPermission}
>
<Text className="text-white font-semibold">Grant Permission</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
return (
<View className="flex-1 bg-gray-900">
{/* Camera View */}
<CameraView
style={StyleSheet.absoluteFillObject}
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
onBarcodeScanned={isScanning ? handleBarcodeScanned : undefined}
/>
{/* Scan Frame Overlay */}
<View className="flex-1 items-center justify-center">
<View className="w-72 h-72 border-4 border-white rounded-3xl opacity-50" />
<Text className="text-white mt-4 text-lg">
{isScanning ? 'Point camera at QR code' : 'Processing...'}
</Text>
</View>
{/* Result Overlay */}
{result && (
<Animated.View
style={{ opacity: fadeAnim }}
className={`absolute inset-0 items-center justify-center ${
result.type === 'success' ? 'bg-green-900/90' : 'bg-red-900/90'
}`}
>
{result.type === 'success' ? (
<CheckCircle size={80} color="#22c55e" />
) : (
<XCircle size={80} color="#ef4444" />
)}
<Text className="text-white text-2xl font-bold mt-6">
{result.title}
</Text>
<Text className="text-white/80 text-center mt-2 px-8">
{result.message}
</Text>
</Animated.View>
)}
{/* Reset Button (when not scanning) */}
{!isScanning && !result && (
<View className="absolute bottom-10 inset-x-0 items-center">
<TouchableOpacity
className="bg-orange-500 px-8 py-4 rounded-xl flex-row items-center"
onPress={resetScanner}
>
<RefreshCw size={20} color="white" />
<Text className="text-white font-semibold ml-2">Scan Again</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
š Scanner Flow
Permission Flow
| Step | Condition | Action |
|---|---|---|
| 1ļøā£ | User opens scanner | Check camera permission |
| 2ļøā£ | Permission granted | Show camera view |
| 3ļøā£ | Permission denied | Show "Request Permission" button |
Scanning Flow
| Step | Action | Result |
|---|---|---|
| 1ļøā£ | Point camera at QR | Detect QR code |
| 2ļøā£ | Parse QR data | Extract eventId:uniqueCode |
| 3ļøā£ | Validate format | Check if both values exist |
| 4ļøā£ | Call recordAttendance() | API POST to /api/attendance |
| 5ļøā£ | Handle response | Success or Error |
Result Feedback
| Result | Animation | Duration | Next Action |
|---|---|---|---|
| ā Success | Green overlay + CheckCircle icon | 2.5 seconds | Auto-reset, ready to scan |
| ā Error | Red overlay + XCircle icon | 2.5 seconds | Auto-reset, ready to scan |
šØ Step 3: Add Haptic Feedback (Optional)
For better user experience, add haptic feedback:
npx expo install expo-haptics
import * as Haptics from 'expo-haptics';
// In handleBarcodeScanned:
if (response.success) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
ā Checklist
- Attendance service created
- Camera permission handling
- QR code scanning with expo-camera
- QR data parsing
- API integration
- Success/error animations
- Auto-reset functionality
š Next Steps
In Part 7, we'll polish the app with splash screen, error boundaries, network status, and deploy to Google Play Store.
ā Part 5: Event Management | Next: Polish & Deploy ā
This series is created to support the Casual Meetup #15 at LampungDev.
