React Native Expo Event Management

1

Part 1: Pengenalan & Arsitektur

2

Part 2: Instalasi di Mac, Windows, Ubuntu

3

Part 3: Project Setup

4

Part 4: Autentikasi Admin

5

Part 5: Manajemen Event

6

Part 6: QR Scanner Kehadiran

7

Part 7: Polish & Deploy

Artikel ini tersedia dalam Bahasa Inggris

๐Ÿ‡ฌ๐Ÿ‡ง Read in English

21 Januari 2026

๐Ÿ‡ฎ๐Ÿ‡ฉ Bahasa Indonesia

React Native Expo Event Management Part 6: QR Scanner Kehadiran

Implementasi QR scanner untuk check-in peserta event menggunakan expo-camera, parsing QR data, record attendance via API, dan feedback animasi.

7 min read


๐Ÿ“– 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

  1. Generate QR code dengan format: eventId:uniqueCode
  2. Buka tab Scanner
  3. Arahkan ke QR code
  4. Harusnya muncul success card dengan nama peserta

Test 2: Scan Invalid QR

  1. Scan QR code random (bukan format yang benar)
  2. Harusnya muncul error card dengan pesan error

Test 3: Double Scan

  1. Scan QR code yang sudah pernah di-scan
  2. 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 database
  • uniqueCode: 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.

Lanjut Membaca

Previous article thumbnail

โ† Artikel Sebelumnya

React Native Expo Event Management Part 5: Manajemen Event

Artikel Selanjutnya โ†’

React Native Expo Event Management Part 7: Polish & Deploy

Next article thumbnail