๐ Overview
Di bagian ini, kita akan membangun fitur QR Scanner untuk check-in kehadiran:
- โ Request camera permission
- โ QR/Barcode scanning dengan expo-camera
- โ Parse QR data (eventId + uniqueCode)
- โ Record attendance via API
- โ Success/error feedback dengan animasi
๐ท Step 1: Request Camera Permission
Pertama, kita buat hook untuk handle camera permission.
File: utils/usePermissions.ts
import { useState, useEffect } from 'react';
import { Camera } from 'expo-camera';
export function useCameraPermission() {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
useEffect(() => {
const getPermission = async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
};
getPermission();
}, []);
const requestPermission = async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
return status === 'granted';
};
return { hasPermission, requestPermission };
}
๐ฑ Step 2: Buat Scanner Screen
File: app/(tabs)/scanner.tsx
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
Alert,
Animated,
Dimensions,
} from 'react-native';
import { CameraView, Camera, BarcodeScanningResult } from 'expo-camera';
import { Check, X, AlertCircle, Camera as CameraIcon } from 'lucide-react-native';
import { recordAttendance } from '@/services/attendance';
const { width } = Dimensions.get('window');
const SCAN_AREA_SIZE = width * 0.7;
type ScanStatus = 'idle' | 'scanning' | 'success' | 'error' | 'already';
interface ScanResult {
status: ScanStatus;
message: string;
attendee?: {
name: string;
email: string;
checkedInAt: string;
};
}
export default function ScannerScreen() {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [isScanning, setIsScanning] = useState(true);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
// Animation
const scaleAnim = useRef(new Animated.Value(0)).current;
const lineAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
const getPermission = async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
};
getPermission();
}, []);
// Scan line animation
useEffect(() => {
if (isScanning) {
Animated.loop(
Animated.sequence([
Animated.timing(lineAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(lineAnim, {
toValue: 0,
duration: 2000,
useNativeDriver: true,
}),
])
).start();
}
}, [isScanning]);
// Result popup animation
const showResultAnimation = () => {
scaleAnim.setValue(0);
Animated.spring(scaleAnim, {
toValue: 1,
friction: 5,
tension: 40,
useNativeDriver: true,
}).start();
};
const handleBarcodeScan = async ({ data }: BarcodeScanningResult) => {
if (!isScanning) return;
setIsScanning(false);
try {
// Parse QR data: format "eventId:uniqueCode"
const [eventId, uniqueCode] = data.split(':');
if (!eventId || !uniqueCode) {
setScanResult({
status: 'error',
message: 'Format QR code tidak valid',
});
showResultAnimation();
return;
}
// Record attendance
const response = await recordAttendance(eventId, uniqueCode);
if (response.success) {
setScanResult({
status: 'success',
message: 'Check-in berhasil!',
attendee: response.data?.registration,
});
} else if (response.error?.includes('already')) {
setScanResult({
status: 'already',
message: 'Peserta sudah check-in sebelumnya',
attendee: response.data?.registration,
});
} else {
setScanResult({
status: 'error',
message: response.error || 'Gagal mencatat kehadiran',
});
}
} catch (error: any) {
console.error('Scan error:', error);
setScanResult({
status: 'error',
message: error.response?.data?.error || 'Terjadi kesalahan',
});
}
showResultAnimation();
};
const resetScanner = () => {
setIsScanning(true);
setScanResult(null);
};
// Permission denied
if (hasPermission === false) {
return (
<View className="flex-1 bg-secondary items-center justify-center px-8">
<CameraIcon size={64} color="#666" />
<Text className="text-white text-xl font-bold mt-6 text-center">
Akses Kamera Diperlukan
</Text>
<Text className="text-gray-400 text-center mt-2">
Izinkan akses kamera untuk scan QR code kehadiran
</Text>
<TouchableOpacity
className="bg-primary px-8 py-4 rounded-xl mt-6"
onPress={async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
}}
>
<Text className="text-white font-bold">Izinkan Akses</Text>
</TouchableOpacity>
</View>
);
}
// Loading permission
if (hasPermission === null) {
return (
<View className="flex-1 bg-secondary items-center justify-center">
<Text className="text-gray-400">Meminta izin kamera...</Text>
</View>
);
}
return (
<View className="flex-1 bg-black">
{/* Camera View */}
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
onBarcodeScanned={isScanning ? handleBarcodeScan : undefined}
>
{/* Overlay */}
<View className="flex-1 items-center justify-center">
{/* Scan Area */}
<View
style={{ width: SCAN_AREA_SIZE, height: SCAN_AREA_SIZE }}
className="border-2 border-white rounded-3xl overflow-hidden"
>
{/* Scan Line Animation */}
{isScanning && (
<Animated.View
className="absolute w-full h-1 bg-primary"
style={{
transform: [
{
translateY: lineAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, SCAN_AREA_SIZE],
}),
},
],
}}
/>
)}
{/* Corner Markers */}
<View className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-2xl" />
<View className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-2xl" />
<View className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-2xl" />
<View className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-2xl" />
</View>
{/* Instructions */}
<Text className="text-white text-center mt-8 text-lg">
Arahkan kamera ke QR Code tiket
</Text>
</View>
{/* Result Overlay */}
{scanResult && (
<Animated.View
className="absolute inset-0 items-center justify-center bg-black/80"
style={{
transform: [{ scale: scaleAnim }],
}}
>
<ResultCard result={scanResult} onReset={resetScanner} />
</Animated.View>
)}
</CameraView>
</View>
);
}
// Result Card Component
function ResultCard({
result,
onReset,
}: {
result: ScanResult;
onReset: () => void;
}) {
const getIcon = () => {
switch (result.status) {
case 'success':
return <Check size={48} color="#22c55e" />;
case 'already':
return <AlertCircle size={48} color="#f59e0b" />;
case 'error':
return <X size={48} color="#ef4444" />;
default:
return null;
}
};
const getBackgroundColor = () => {
switch (result.status) {
case 'success':
return 'bg-green-900/50';
case 'already':
return 'bg-yellow-900/50';
case 'error':
return 'bg-red-900/50';
default:
return 'bg-gray-800';
}
};
return (
<View className={`mx-8 p-8 rounded-3xl ${getBackgroundColor()}`}>
{/* Icon */}
<View className="items-center mb-4">{getIcon()}</View>
{/* Message */}
<Text className="text-white text-xl font-bold text-center mb-2">
{result.message}
</Text>
{/* Attendee Info */}
{result.attendee && (
<View className="bg-black/30 rounded-xl p-4 mt-4">
<Text className="text-white font-semibold text-lg text-center">
{result.attendee.name}
</Text>
<Text className="text-gray-400 text-center mt-1">
{result.attendee.email}
</Text>
</View>
)}
{/* Scan Again Button */}
<TouchableOpacity
className="bg-primary py-4 rounded-xl mt-6"
onPress={onReset}
>
<Text className="text-white font-bold text-center text-lg">
Scan Lagi
</Text>
</TouchableOpacity>
</View>
);
}
๐ Step 3: Update Attendance Service
Pastikan service sudah menangani semua response.
File: services/attendance.ts
import { api } from './api';
interface AttendanceResponse {
success: boolean;
message?: string;
data?: {
registration: {
id: string;
name: string;
email: 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) {
// Handle specific errors
if (error.response?.status === 404) {
return {
success: false,
error: 'Tiket tidak ditemukan atau tidak terdaftar untuk event ini',
};
}
if (error.response?.status === 409) {
return {
success: false,
error: 'Peserta sudah check-in sebelumnya',
data: error.response.data.data,
};
}
throw error;
}
};
๐ Step 4: Tambahkan Scan History (Opsional)
Untuk tracking scan yang sudah dilakukan dalam session.
File: store/scan.ts
import { create } from 'zustand';
interface ScanRecord {
id: string;
eventId: string;
attendeeName: string;
attendeeEmail: string;
scannedAt: string;
status: 'success' | 'already' | 'error';
}
interface ScanState {
history: ScanRecord[];
addRecord: (record: ScanRecord) => void;
clearHistory: () => void;
}
export const useScanStore = create<ScanState>((set) => ({
history: [],
addRecord: (record) =>
set((state) => ({
history: [record, ...state.history].slice(0, 50), // Keep last 50
})),
clearHistory: () => set({ history: [] }),
}));
๐ Step 5: Tambahkan Sound Feedback (Opsional)
Tambahkan haptic feedback untuk UX yang lebih baik.
Install:
npx expo install expo-haptics
Update Scanner Screen:
import * as Haptics from 'expo-haptics';
// Di dalam handleBarcodeScan setelah response
if (response.success) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// ... rest of code
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
// ... rest of code
}
๐งช Testing Scanner
Test 1: Scan Valid QR
- Generate QR code dengan format:
eventId:uniqueCode - Buka tab Scanner
- Arahkan ke QR code
- Harusnya muncul success card dengan nama peserta
Test 2: Scan Invalid QR
- Scan QR code random (bukan format yang benar)
- Harusnya muncul error card dengan pesan error
Test 3: Double Scan
- Scan QR code yang sudah pernah di-scan
- Harusnya muncul warning "Peserta sudah check-in sebelumnya"
๐ฑ QR Code Format
Format QR code yang digunakan:
{eventId}:{uniqueCode}
Contoh:
cm123abc:uk789xyz
Di mana:
eventId: ID event di databaseuniqueCode: Kode unik registrasi peserta
โ Checklist
- Camera permission handling dibuat
- Scanner screen dengan CameraView dibuat
- QR parsing berfungsi
- API call untuk record attendance berfungsi
- Result card dengan animasi dibuat
- Haptic feedback (opsional) ditambahkan
๐ Selanjutnya
Di Part 7 (final), kita akan melakukan finishing touches, build APK/AAB, dan publish ke Play Store.
โ Part 5: Manajemen Event | Part 7: Polish & Deploy โ
Series ini dibuat untuk mendukung materi Casual Meetup #15 di LampungDev.
