Add blank-badge assignment and improve offline check-in flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-09-30 09:39:46 +05:30
parent 2f6b838939
commit 51e679d6ec
21 changed files with 1656 additions and 232 deletions

Binary file not shown.

View File

@ -34,9 +34,15 @@ If Gradle reports `Could not find org.asyncstorage.shared_storage:storage-androi
1. While online, open **Events** and tap the cloud icon (or long-press an event) → **Enable Offline**.
2. The app downloads active attendance sessions + participant rosters into local SQLite.
3. Check in via search or QR without internet; items queue in an outbox.
4. Tap **Sync** (or wait for reconnect) to flush via `POST .../check-ins/sync`.
5. **Refresh Offline Data** updates the snapshot without clearing pending outbox rows.
3. While Offline is on, search and QR check-ins always save locally (even with internet). They queue in an outbox.
4. Every minute while online (app in the foreground), pending check-ins are **uploaded**. Reconnect and returning to the app also upload.
5. Header **Sync** is two-way at that page’s scope:
- **Events** — all Offline-enabled events (upload pending + refresh all sheets)
- **Attendance sessions** — this event (upload pending + refresh all sheets)
- **Participants** — this attendance sheet only (upload pending + refresh this roster)
6. **Refresh Offline Data** (long-press an event) is a recovery pull of the snapshot without clearing pending outbox rows.
7. **Assign a Blank** (when enabled on the attendance session in the dashboard) scans a spare badge QR, lets staff edit name/email/phone, then saves — updating that participant and checking them in. It uses the same offline outbox as check-ins.
8. **Undo** on the check-in success sheet reverses the last check-in. For a later mistake, swipe a checked-in row left to reveal **Uncheck-in**. Staff-only; last check-in only; same offline queue. Does not revert Assign a Blank profile.
Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`).

49
package-lock.json generated
View File

@ -17,6 +17,7 @@
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-config": "^1.6.1",
"react-native-gesture-handler": "^2.28.0",
"react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "^5.5.2",
"react-native-screens": "^4.25.1",
@ -2026,6 +2027,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -3621,6 +3634,12 @@
"@types/node": "*"
}
},
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -7121,6 +7140,21 @@
"hermes-estree": "0.33.3"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -10465,6 +10499,21 @@
}
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
"license": "MIT",
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-get-random-values": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",

View File

@ -33,6 +33,30 @@ export const attendancesApi = {
);
},
undoCheckIn(eventId: string, attendanceId: string, participantId: string) {
return api.post<AttendanceCheckInResult>(
`/events/${eventId}/attendances/${attendanceId}/undo-check-in`,
{ participantId },
);
},
assignBlank(
eventId: string,
attendanceId: string,
body: {
participantId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
},
) {
return api.post<AttendanceCheckInResult>(
`/events/${eventId}/attendances/${attendanceId}/assign-blank`,
body,
);
},
syncCheckIns(
eventId: string,
attendanceId: string,

View File

@ -43,6 +43,7 @@ export interface AttendanceRecord {
status: 'active' | 'archived';
settings?: {
allowDuplicateCheckIn?: boolean;
allowAssignBlank?: boolean;
publicLinkEnabled?: boolean;
};
uniqueParticipants?: number;
@ -74,10 +75,19 @@ export interface AttendanceCheckInResult {
alreadyCheckedIn: boolean;
}
export interface AssignBlankProfile {
firstName: string;
lastName?: string;
email?: string;
phone?: string;
}
export interface OfflineSyncOperation {
clientOpId: string;
participantId: string;
checkedInAt: string;
profile?: AssignBlankProfile;
action?: 'check_in' | 'undo';
}
export interface OfflineSyncOpResult {

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { Alert, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AttendanceCheckInResult } from '../api/types';
import { colors, radii, spacing, typography } from '../theme';
@ -11,6 +11,8 @@ type Props = {
error: string | null;
pendingSync?: boolean;
onClose: () => void;
onUndo?: () => void | Promise<void>;
undoLoading?: boolean;
};
const CHECKIN_GREEN = '#16A34A';
@ -22,8 +24,25 @@ export function CheckInResultModal({
error,
pendingSync = false,
onClose,
onUndo,
undoLoading = false,
}: Props) {
const insets = useSafeAreaInsets();
const showUndo = Boolean(result && onUndo && !error);
const confirmUndo = () => {
if (!result || !onUndo || undoLoading) return;
Alert.alert('Undo check-in', `Undo check-in for ${result.displayName}?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Undo',
style: 'destructive',
onPress: () => {
void onUndo();
},
},
]);
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
@ -85,7 +104,23 @@ export function CheckInResultModal({
</>
) : null}
<PrimaryButton title="Done" onPress={onClose} style={styles.doneBtn} />
<View style={styles.actions}>
{showUndo ? (
<Pressable
style={styles.undoBtn}
onPress={confirmUndo}
disabled={undoLoading}
>
<Text style={styles.undoText}>{undoLoading ? 'Undoing…' : 'Undo'}</Text>
</Pressable>
) : null}
<PrimaryButton
title="Done"
onPress={onClose}
style={showUndo ? styles.doneBtnFlex : styles.doneBtn}
disabled={undoLoading}
/>
</View>
</Pressable>
</Pressable>
</Modal>
@ -177,7 +212,30 @@ const styles = StyleSheet.create({
color: PENDING_AMBER,
fontWeight: '600',
},
doneBtn: {
actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: spacing.sm,
},
undoBtn: {
height: 52,
paddingHorizontal: 18,
borderRadius: radii.md,
borderWidth: 1.5,
borderColor: colors.error,
alignItems: 'center',
justifyContent: 'center',
},
undoText: {
color: colors.error,
fontSize: 16,
fontWeight: '700',
},
doneBtn: {
flex: 1,
},
doneBtnFlex: {
flex: 1,
},
});

159
src/components/SwipeRow.tsx Normal file
View File

@ -0,0 +1,159 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
PanResponder,
Pressable,
StyleSheet,
Text,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { colors, radii } from '../theme';
const ACTION_WIDTH = 108;
const OPEN_THRESHOLD = 40;
type Props = {
enabled: boolean;
open: boolean;
onOpen: () => void;
onClose: () => void;
actionLabel: string;
onActionPress: () => void;
actionBusy?: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};
export function SwipeRow({
enabled,
open,
onOpen,
onClose,
actionLabel,
onActionPress,
actionBusy = false,
children,
style,
}: Props) {
const translateX = useRef(new Animated.Value(0)).current;
const startX = useRef(0);
const openRef = useRef(open);
openRef.current = open;
const [rowHeight, setRowHeight] = useState(0);
const snapTo = (toValue: number, after?: () => void) => {
Animated.spring(translateX, {
toValue,
useNativeDriver: true,
bounciness: 0,
speed: 20,
}).start(({ finished }) => {
if (finished) after?.();
});
};
useEffect(() => {
snapTo(open ? -ACTION_WIDTH : 0);
}, [open]);
const pan = useMemo(
() =>
PanResponder.create({
onMoveShouldSetPanResponder: (_e, g) => {
if (!enabled) return false;
return Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy);
},
onMoveShouldSetPanResponderCapture: (_e, g) => {
if (!enabled) return false;
return Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy);
},
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
translateX.stopAnimation((value) => {
startX.current = value;
});
},
onPanResponderMove: (_e, g) => {
const next = Math.min(0, Math.max(-ACTION_WIDTH, startX.current + g.dx));
translateX.setValue(next);
},
onPanResponderRelease: (_e, g) => {
const projected = startX.current + g.dx;
const closingSwipe = openRef.current && (g.dx > OPEN_THRESHOLD || g.vx > 0.35);
const shouldOpen =
!closingSwipe && (projected < -OPEN_THRESHOLD || g.vx < -0.4);
if (shouldOpen) {
snapTo(-ACTION_WIDTH, onOpen);
} else {
snapTo(0, onClose);
}
},
onPanResponderTerminate: () => {
snapTo(openRef.current ? -ACTION_WIDTH : 0);
},
}),
[enabled, onClose, onOpen, translateX],
);
const onForegroundLayout = (e: LayoutChangeEvent) => {
const next = Math.round(e.nativeEvent.layout.height);
if (next > 0 && next !== rowHeight) setRowHeight(next);
};
if (!enabled) return <View style={style}>{children}</View>;
return (
<View style={style}>
<View style={styles.clip} {...pan.panHandlers}>
{rowHeight > 0 ? (
<View
style={[styles.actionWrap, { height: rowHeight }]}
pointerEvents={open ? 'auto' : 'none'}
>
<Pressable style={styles.action} onPress={actionBusy ? undefined : onActionPress}>
<Text style={styles.actionText}>{actionBusy ? '…' : actionLabel}</Text>
</Pressable>
</View>
) : null}
<Animated.View
style={[styles.foreground, { transform: [{ translateX }] }]}
onLayout={onForegroundLayout}
>
{children}
</Animated.View>
</View>
</View>
);
}
const styles = StyleSheet.create({
clip: {
overflow: 'hidden',
borderRadius: radii.xl,
},
actionWrap: {
position: 'absolute',
right: 0,
top: 0,
width: ACTION_WIDTH,
},
action: {
flex: 1,
backgroundColor: colors.error,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 10,
},
actionText: {
color: '#fff',
fontSize: 12,
fontWeight: '700',
textAlign: 'center',
},
foreground: {
backgroundColor: colors.card,
},
});

View File

@ -23,6 +23,7 @@ export type AppIconName =
| 'log-out-outline'
| 'mail-outline'
| 'people-outline'
| 'person-add-outline'
| 'person-outline'
| 'qr-code-outline'
| 'refresh-outline'

View File

@ -9,6 +9,7 @@ import { EventsScreen } from '../screens/EventsScreen';
import { AttendanceSessionsScreen } from '../screens/AttendanceSessionsScreen';
import { ParticipantsScreen } from '../screens/ParticipantsScreen';
import { QrScannerScreen } from '../screens/QrScannerScreen';
import { AssignBlankScreen } from '../screens/AssignBlankScreen';
import type { RootStackParamList } from './types';
import { colors } from '../theme';
import { getAccessToken } from '../api/client';
@ -89,6 +90,7 @@ export function RootNavigator() {
<Stack.Screen name="Events" component={EventsScreen} />
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
<Stack.Screen name="Participants" component={ParticipantsScreen} />
<Stack.Screen name="AssignBlank" component={AssignBlankScreen} />
<Stack.Screen
name="QrScanner"
component={QrScannerScreen}

View File

@ -9,11 +9,26 @@ export type RootStackParamList = {
attendanceId: string;
attendanceName: string;
allowDuplicate: boolean;
allowAssignBlank: boolean;
};
QrScanner: {
eventId: string;
eventName: string;
attendanceId: string;
attendanceName: string;
allowDuplicate: boolean;
allowAssignBlank?: boolean;
mode?: 'checkIn' | 'assignBlank';
};
AssignBlank: {
eventId: string;
eventName: string;
attendanceId: string;
attendanceName: string;
participantId: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
};

View File

@ -9,7 +9,7 @@ import React, {
} from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { getDb } from './db';
import { runSync, subscribeSyncEngine } from './syncEngine';
import { runSync, subscribeSyncEngine, type SyncOpts, type SyncResult } from './syncEngine';
import type { EventOfflineStatus } from './types';
import { getEventOfflineStatus, listOfflineEventIds } from './repos';
@ -20,11 +20,13 @@ type OfflineContextValue = {
lastSyncError: string | null;
statusByEvent: Record<string, EventOfflineStatus>;
refreshStatuses: () => Promise<void>;
syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>;
syncNow: (opts?: SyncOpts) => Promise<SyncResult>;
};
const OfflineContext = createContext<OfflineContextValue | null>(null);
const AUTO_SYNC_MS = 60_000;
export function OfflineProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [isOnline, setIsOnline] = useState(true);
@ -44,8 +46,8 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
}, []);
const syncNow = useCallback(
async (eventId?: string) => {
const result = await runSync(eventId);
async (opts?: SyncOpts) => {
const result = await runSync(opts);
await refreshStatuses();
return result;
},
@ -81,7 +83,7 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
const online = Boolean(state.isConnected && state.isInternetReachable !== false);
setIsOnline(online);
if (online) {
syncNow().catch(() => undefined);
syncNow({ pull: false }).catch(() => undefined);
}
});
return () => unsub();
@ -90,13 +92,23 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const onAppState = (next: AppStateStatus) => {
if (next === 'active') {
syncNow().catch(() => undefined);
syncNow({ pull: false }).catch(() => undefined);
}
};
const sub = AppState.addEventListener('change', onAppState);
return () => sub.remove();
}, [syncNow]);
const hasPacks = Object.values(statusByEvent).some((s) => s.enabled);
useEffect(() => {
if (!ready || !isOnline || !hasPacks) return;
const id = setInterval(() => {
syncNow({ pull: false }).catch(() => undefined);
}, AUTO_SYNC_MS);
return () => clearInterval(id);
}, [ready, isOnline, hasPacks, syncNow]);
const value = useMemo(
() => ({
ready,

View File

@ -1,16 +1,18 @@
import NetInfo from '@react-native-community/netinfo';
import { v4 as uuidv4 } from 'uuid';
import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult } from '../api/types';
import type { AssignBlankProfile, AttendanceCheckInResult } from '../api/types';
import { getDeviceId } from './deviceId';
import {
applyLocalCheckIn,
countPendingForSession,
applyLocalProfile,
applyLocalUndo,
deleteOutbox,
findLatestPendingOutboxForParticipant,
getOfflineParticipant,
getOfflineSession,
insertOutbox,
isOfflinePackActive,
updateParticipantFromSync,
} from './repos';
export type CheckInServiceResult =
@ -22,17 +24,27 @@ async function isOnline(): Promise<boolean> {
return Boolean(state.isConnected && state.isInternetReachable !== false);
}
function displayNameFromProfile(profile: AssignBlankProfile, fallback?: string | null) {
const fromNames = [profile.firstName, profile.lastName].filter(Boolean).join(' ').trim();
return fromNames || profile.email || fallback || 'Participant';
}
async function queueLocalCheckIn(opts: {
eventId: string;
attendanceId: string;
attendanceName: string;
participantId: string;
profile?: AssignBlankProfile;
}): Promise<CheckInServiceResult> {
const session = await getOfflineSession(opts.attendanceId);
if (!session) {
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
}
if (opts.profile && session.allowAssignBlank !== 1) {
return { ok: false, error: 'Assign a Blank is not enabled for this attendance session.' };
}
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
if (!participant) {
return {
@ -57,10 +69,16 @@ async function queueLocalCheckIn(opts: {
participantId: opts.participantId,
checkedInAt,
deviceId,
profile: opts.profile ?? null,
});
if (opts.profile) {
await applyLocalProfile(opts.attendanceId, opts.participantId, opts.profile);
}
await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt);
const nextCount = participant.checkInCount + 1;
const email = opts.profile?.email ?? participant.email ?? '';
const phone = opts.profile?.phone ?? participant.phone ?? '';
return {
ok: true,
pendingSync: true,
@ -68,9 +86,11 @@ async function queueLocalCheckIn(opts: {
attendanceId: opts.attendanceId,
attendanceName: opts.attendanceName || session.name,
participantId: opts.participantId,
displayName: participant.displayName,
email: participant.email || '',
phone: participant.phone || '',
displayName: opts.profile
? displayNameFromProfile(opts.profile, participant.displayName)
: participant.displayName,
email,
phone,
checkInCount: nextCount,
lastCheckedInAt: checkedInAt,
allowDuplicateCheckIn: allowDuplicate,
@ -80,7 +100,7 @@ async function queueLocalCheckIn(opts: {
}
/**
* Check-in: live API when online with empty outbox; otherwise local outbox.
* Check-in: always local outbox when Offline is enabled; live API otherwise.
*/
export async function performAppCheckIn(opts: {
eventId: string;
@ -89,15 +109,12 @@ export async function performAppCheckIn(opts: {
participantId: string;
}): Promise<CheckInServiceResult> {
const packActive = await isOfflinePackActive(opts.eventId);
const pending = await countPendingForSession(opts.attendanceId);
const online = await isOnline();
if (packActive && (!online || pending > 0)) {
if (packActive) {
return queueLocalCheckIn(opts);
}
const online = await isOnline();
if (!online) {
if (packActive) return queueLocalCheckIn(opts);
return {
ok: false,
error: 'You are offline. Enable Offline for this event first to check in without internet.',
@ -106,21 +123,136 @@ export async function performAppCheckIn(opts: {
const res = await attendancesApi.checkIn(opts.eventId, opts.attendanceId, opts.participantId);
if (res.error || !res.data) {
// Network flake after we thought we were online — fall back to outbox if pack exists
if (res.status === 0 && packActive) {
return queueLocalCheckIn(opts);
}
return { ok: false, error: res.error || 'Check-in failed' };
}
if (packActive) {
await updateParticipantFromSync(
opts.attendanceId,
opts.participantId,
res.data.checkInCount,
res.data.lastCheckedInAt,
);
}
return { ok: true, result: res.data, pendingSync: false };
}
export async function performAppAssignBlank(opts: {
eventId: string;
attendanceId: string;
attendanceName: string;
participantId: string;
profile: AssignBlankProfile;
}): Promise<CheckInServiceResult> {
const firstName = opts.profile.firstName.trim();
if (!firstName) {
return { ok: false, error: 'First name is required' };
}
const profile: AssignBlankProfile = {
firstName,
lastName: (opts.profile.lastName || '').trim(),
email: (opts.profile.email || '').trim().toLowerCase(),
phone: (opts.profile.phone || '').trim(),
};
const packActive = await isOfflinePackActive(opts.eventId);
if (packActive) {
return queueLocalCheckIn({ ...opts, profile });
}
const online = await isOnline();
if (!online) {
return {
ok: false,
error: 'You are offline. Enable Offline for this event first to assign a blank badge.',
};
}
const res = await attendancesApi.assignBlank(opts.eventId, opts.attendanceId, {
participantId: opts.participantId,
...profile,
});
if (res.error || !res.data) {
return { ok: false, error: res.error || 'Assign blank failed' };
}
return { ok: true, result: res.data, pendingSync: false };
}
export async function performAppUndoCheckIn(opts: {
eventId: string;
attendanceId: string;
attendanceName: string;
participantId: string;
}): Promise<CheckInServiceResult> {
const packActive = await isOfflinePackActive(opts.eventId);
if (packActive) {
const session = await getOfflineSession(opts.attendanceId);
if (!session) {
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
}
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
if (!participant) {
return {
ok: false,
error: 'Participant not in offline pack. Refresh offline data while online.',
};
}
if (participant.alreadyCheckedIn !== 1 || participant.checkInCount < 1) {
return { ok: false, error: 'Participant is not checked in' };
}
const latest = await findLatestPendingOutboxForParticipant(
opts.attendanceId,
opts.participantId,
);
await applyLocalUndo(opts.attendanceId, opts.participantId);
let pendingSync = false;
if (latest && latest.action !== 'undo' && !latest.profile) {
await deleteOutbox(latest.clientOpId);
} else {
const clientOpId = uuidv4();
const deviceId = await getDeviceId();
await insertOutbox({
clientOpId,
eventId: opts.eventId,
attendanceId: opts.attendanceId,
participantId: opts.participantId,
checkedInAt: new Date().toISOString(),
deviceId,
action: 'undo',
});
pendingSync = true;
}
const nextCount = Math.max(0, participant.checkInCount - 1);
return {
ok: true,
pendingSync,
result: {
attendanceId: opts.attendanceId,
attendanceName: opts.attendanceName || session.name,
participantId: opts.participantId,
displayName: participant.displayName,
email: participant.email ?? '',
phone: participant.phone ?? '',
checkInCount: nextCount,
lastCheckedInAt: nextCount > 0 ? participant.lastCheckedInAt : null,
allowDuplicateCheckIn: session.allowDuplicateCheckIn === 1,
alreadyCheckedIn: nextCount > 0,
},
};
}
const online = await isOnline();
if (!online) {
return {
ok: false,
error: 'You are offline. Enable Offline for this event first to undo a check-in.',
};
}
const res = await attendancesApi.undoCheckIn(opts.eventId, opts.attendanceId, opts.participantId);
if (res.error || !res.data) {
const err =
res.status === 404
? 'Undo API not found on this server. Point the app at a backend that includes POST .../undo-check-in (restart local backend, or deploy it).'
: res.error || 'Undo check-in failed';
return { ok: false, error: err };
}
return { ok: true, result: res.data, pendingSync: false };
}

View File

@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS offline_sessions (
name TEXT NOT NULL,
status TEXT NOT NULL,
allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0,
allow_assign_blank INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE
);
@ -59,6 +60,8 @@ CREATE TABLE IF NOT EXISTS outbox (
device_id TEXT NOT NULL,
status TEXT NOT NULL,
last_error TEXT,
profile_json TEXT,
action TEXT NOT NULL DEFAULT 'check_in',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -68,6 +71,14 @@ CREATE INDEX IF NOT EXISTS idx_outbox_event ON outbox(event_id, status);
CREATE INDEX IF NOT EXISTS idx_outbox_session ON outbox(attendance_id, status);
`;
async function ensureColumn(instance: DB, table: string, column: string, ddl: string): Promise<void> {
const res = await instance.execute(`PRAGMA table_info(${table})`);
const has = res.rows.some((r) => String(r.name) === column);
if (!has) {
await instance.execute(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
}
export async function getDb(): Promise<DB> {
if (db) return db;
if (!readyPromise) {
@ -80,6 +91,19 @@ export async function getDb(): Promise<DB> {
.filter(Boolean)
.map((s) => [`${s};`] as [string]),
);
await ensureColumn(
instance,
'offline_sessions',
'allow_assign_blank',
'allow_assign_blank INTEGER NOT NULL DEFAULT 0',
);
await ensureColumn(instance, 'outbox', 'profile_json', 'profile_json TEXT');
await ensureColumn(
instance,
'outbox',
'action',
"action TEXT NOT NULL DEFAULT 'check_in'",
);
db = instance;
return instance;
})();

View File

@ -5,8 +5,10 @@ import type {
OfflineEventRow,
OfflineParticipantRow,
OfflineSessionRow,
OutboxAction,
OutboxRow,
OutboxStatus,
AssignBlankProfile,
} from './types';
function nowIso() {
@ -87,14 +89,15 @@ export async function replaceSessionsForEvent(
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
for (const s of sessions) {
await db.execute(
`INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in)
VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in, allow_assign_blank)
VALUES (?, ?, ?, ?, ?, ?)`,
[
s._id,
eventId,
s.name,
s.status,
s.settings?.allowDuplicateCheckIn ? 1 : 0,
s.settings?.allowAssignBlank ? 1 : 0,
],
);
}
@ -112,6 +115,7 @@ export async function listOfflineSessions(eventId: string): Promise<OfflineSessi
name: String(row.name),
status: String(row.status),
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
allowAssignBlank: Number(row.allow_assign_blank) || 0,
}));
}
@ -126,6 +130,7 @@ export async function getOfflineSession(attendanceId: string): Promise<OfflineSe
name: String(row.name),
status: String(row.status),
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
allowAssignBlank: Number(row.allow_assign_blank) || 0,
};
}
@ -220,6 +225,36 @@ export async function getOfflineParticipant(
return mapParticipant(row as Record<string, unknown>);
}
export async function applyLocalProfile(
attendanceId: string,
participantId: string,
profile: AssignBlankProfile,
): Promise<void> {
const db = await getDb();
const displayName =
[profile.firstName, profile.lastName].filter(Boolean).join(' ').trim() ||
profile.email ||
'Participant';
await db.execute(
`UPDATE offline_participants
SET first_name = ?,
last_name = ?,
email = ?,
phone = ?,
display_name = ?
WHERE attendance_id = ? AND participant_id = ?`,
[
profile.firstName,
profile.lastName ?? '',
profile.email ?? '',
profile.phone ?? '',
displayName,
attendanceId,
participantId,
],
);
}
export async function applyLocalCheckIn(
attendanceId: string,
participantId: string,
@ -236,6 +271,18 @@ export async function applyLocalCheckIn(
);
}
export async function applyLocalUndo(attendanceId: string, participantId: string): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE offline_participants
SET already_checked_in = CASE WHEN check_in_count <= 1 THEN 0 ELSE 1 END,
last_checked_in_at = CASE WHEN check_in_count <= 1 THEN NULL ELSE last_checked_in_at END,
check_in_count = MAX(0, check_in_count - 1)
WHERE attendance_id = ? AND participant_id = ?`,
[attendanceId, participantId],
);
}
export async function updateParticipantFromSync(
attendanceId: string,
participantId: string,
@ -260,14 +307,16 @@ export async function insertOutbox(row: {
participantId: string;
checkedInAt: string;
deviceId: string;
profile?: AssignBlankProfile | null;
action?: OutboxAction;
}): Promise<void> {
const db = await getDb();
const ts = nowIso();
await db.execute(
`INSERT INTO outbox (
client_op_id, event_id, attendance_id, participant_id, checked_in_at,
device_id, status, last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`,
device_id, status, last_error, profile_json, action, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, ?, ?)`,
[
row.clientOpId,
row.eventId,
@ -275,12 +324,29 @@ export async function insertOutbox(row: {
row.participantId,
row.checkedInAt,
row.deviceId,
row.profile ? JSON.stringify(row.profile) : null,
row.action || 'check_in',
ts,
ts,
],
);
}
function parseOutboxAction(raw: unknown): OutboxAction {
return String(raw) === 'undo' ? 'undo' : 'check_in';
}
function parseProfileJson(raw: unknown): AssignBlankProfile | null {
if (raw == null || raw === '') return null;
try {
const parsed = JSON.parse(String(raw)) as AssignBlankProfile;
if (!parsed || typeof parsed.firstName !== 'string') return null;
return parsed;
} catch {
return null;
}
}
function mapOutbox(row: Record<string, unknown>): OutboxRow {
return {
clientOpId: String(row.client_op_id),
@ -293,22 +359,50 @@ function mapOutbox(row: Record<string, unknown>): OutboxRow {
lastError: row.last_error != null ? String(row.last_error) : null,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
profile: parseProfileJson(row.profile_json),
action: parseOutboxAction(row.action),
};
}
export async function listPendingOutbox(eventId?: string): Promise<OutboxRow[]> {
export async function listPendingOutbox(scope?: {
eventId?: string;
attendanceId?: string;
}): Promise<OutboxRow[]> {
const db = await getDb();
const res = eventId
const res = scope?.attendanceId
? await db.execute(
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
[eventId],
`SELECT * FROM outbox WHERE attendance_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
[scope.attendanceId],
)
: await db.execute(
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`,
);
: scope?.eventId
? await db.execute(
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
[scope.eventId],
)
: await db.execute(
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`,
);
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
}
/** Overlay leftover outbox rows onto the local roster after a server pull. */
export async function reapplyPendingOutboxToRoster(scope?: {
eventId?: string;
attendanceId?: string;
}): Promise<void> {
const pending = await listPendingOutbox(scope);
for (const row of pending) {
if (row.action === 'undo') {
await applyLocalUndo(row.attendanceId, row.participantId);
continue;
}
if (row.profile) {
await applyLocalProfile(row.attendanceId, row.participantId, row.profile);
}
await applyLocalCheckIn(row.attendanceId, row.participantId, row.checkedInAt);
}
}
export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> {
const db = await getDb();
const res = await db.execute(
@ -340,6 +434,27 @@ export async function countPendingForSession(attendanceId: string): Promise<numb
return Number(res.rows[0]?.c) || 0;
}
export async function findLatestPendingOutboxForParticipant(
attendanceId: string,
participantId: string,
): Promise<OutboxRow | null> {
const db = await getDb();
const res = await db.execute(
`SELECT * FROM outbox
WHERE attendance_id = ? AND participant_id = ? AND status IN ('pending', 'syncing')
ORDER BY created_at DESC LIMIT 1`,
[attendanceId, participantId],
);
const row = res.rows[0];
if (!row) return null;
return mapOutbox(row as Record<string, unknown>);
}
export async function deleteOutbox(clientOpId: string): Promise<void> {
const db = await getDb();
await db.execute(`DELETE FROM outbox WHERE client_op_id = ?`, [clientOpId]);
}
export async function markOutboxStatus(
clientOpId: string,
status: OutboxStatus,

View File

@ -1,16 +1,33 @@
import NetInfo from '@react-native-community/netinfo';
import { attendancesApi } from '../api/attendances';
import { getDeviceId } from './deviceId';
import { refreshSessionRoster } from './download';
import { refreshOfflinePack, refreshSessionRoster } from './download';
import {
getOfflineEvent,
getOfflineSession,
listOfflineEventIds,
listPendingOutbox,
markOutboxStatus,
markOutboxSyncing,
reapplyPendingOutboxToRoster,
setEventLastSyncedAt,
updateParticipantFromSync,
} from './repos';
import type { SyncOpResult } from './types';
export type SyncOpts = {
eventId?: string;
attendanceId?: string;
pull?: boolean;
};
export type SyncResult = {
ok: boolean;
flushed: number;
pulled: boolean;
error?: string;
};
type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void;
let syncing = false;
@ -31,16 +48,75 @@ export function isSyncEngineRunning(): boolean {
return syncing;
}
export function formatSyncAlert(result: SyncResult): string {
if (!result.ok && result.error && result.flushed === 0 && !result.pulled) {
return result.error;
}
const bits: string[] = [];
if (result.flushed > 0) {
bits.push(`Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}`);
}
if (result.pulled) {
bits.push(result.flushed > 0 ? 'roster refreshed' : 'Roster updated');
}
if (!bits.length) {
return result.error || 'Already up to date.';
}
const msg = bits.join(' · ');
return result.error ? `${msg}. ${result.error}` : msg;
}
function mergeSyncOpts(a: SyncOpts, b: SyncOpts): SyncOpts {
const pull = Boolean(a.pull || b.pull);
if (!a.eventId || !b.eventId || a.eventId !== b.eventId) {
return { pull };
}
if (!a.attendanceId || !b.attendanceId || a.attendanceId !== b.attendanceId) {
return { eventId: a.eventId, pull };
}
return { eventId: a.eventId, attendanceId: a.attendanceId, pull };
}
type Deferred = {
promise: Promise<SyncResult>;
resolve: (r: SyncResult) => void;
};
function createDeferred(): Deferred {
let resolve: (r: SyncResult) => void = () => undefined;
const promise = new Promise<SyncResult>((res) => {
resolve = res;
});
return { promise, resolve };
}
let running = false;
let queuedOpts: SyncOpts | null = null;
let queuedDeferred: Deferred | null = null;
async function flushGroup(
eventId: string,
attendanceId: string,
ops: { clientOpId: string; participantId: string; checkedInAt: string }[],
ops: {
clientOpId: string;
participantId: string;
checkedInAt: string;
profile?: { firstName: string; lastName?: string; email?: string; phone?: string } | null;
action?: 'check_in' | 'undo';
}[],
deviceId: string,
refreshRoster: boolean,
): Promise<string | null> {
await markOutboxSyncing(ops.map((o) => o.clientOpId));
const res = await attendancesApi.syncCheckIns(eventId, attendanceId, {
deviceId,
operations: ops,
operations: ops.map((o) => ({
clientOpId: o.clientOpId,
participantId: o.participantId,
checkedInAt: o.checkedInAt,
...(o.profile ? { profile: o.profile } : {}),
...(o.action === 'undo' ? { action: 'undo' as const } : {}),
})),
});
if (res.error || !res.data) {
@ -88,38 +164,70 @@ async function flushGroup(
}
}
// Light roster refresh to pick up other devices
await refreshSessionRoster(eventId, attendanceId);
if (refreshRoster) {
const rr = await refreshSessionRoster(eventId, attendanceId);
if (rr.ok) {
await reapplyPendingOutboxToRoster({ attendanceId });
}
}
return null;
}
/**
* Flush pending outbox. Optionally scope to one event.
*/
export async function runSync(eventId?: string): Promise<{
ok: boolean;
flushed: number;
error?: string;
}> {
if (syncing) {
return { ok: false, flushed: 0, error: 'Sync already in progress' };
async function pullSheets(opts: SyncOpts): Promise<{ eventIds: string[]; error?: string }> {
if (opts.attendanceId) {
let eventId = opts.eventId;
if (!eventId) {
const session = await getOfflineSession(opts.attendanceId);
eventId = session?.eventId;
}
if (!eventId) {
return { eventIds: [], error: 'Offline pack missing this attendance session.' };
}
const res = await refreshSessionRoster(eventId, opts.attendanceId);
if (!res.ok) {
return { eventIds: [], error: res.error };
}
await reapplyPendingOutboxToRoster({ attendanceId: opts.attendanceId });
return { eventIds: [eventId] };
}
const ids = opts.eventId ? [opts.eventId] : await listOfflineEventIds();
let lastError: string | undefined;
const eventIds: string[] = [];
for (const id of ids) {
const event = await getOfflineEvent(id);
if (!event) continue;
const res = await refreshOfflinePack({
_id: event.id,
name: event.name,
startDate: event.startDate ?? undefined,
endDate: event.endDate ?? undefined,
location: event.location ?? undefined,
});
if (!res.ok) {
lastError = res.error;
continue;
}
eventIds.push(id);
await reapplyPendingOutboxToRoster({ eventId: id });
}
return { eventIds, error: lastError };
}
async function executeSync(opts: SyncOpts): Promise<SyncResult> {
const net = await NetInfo.fetch();
if (!net.isConnected || net.isInternetReachable === false) {
return { ok: false, flushed: 0, error: 'No internet connection' };
return { ok: false, flushed: 0, pulled: false, error: 'No internet connection' };
}
syncing = true;
emit(null);
try {
const pending = await listPendingOutbox(eventId);
if (!pending.length) {
if (eventId) await setEventLastSyncedAt(eventId, new Date().toISOString());
return { ok: true, flushed: 0 };
}
const scope = { eventId: opts.eventId, attendanceId: opts.attendanceId };
const pending = await listPendingOutbox(scope);
const pull = Boolean(opts.pull);
let flushed = 0;
let lastError: string | null = null;
const touchedEvents = new Set<string>();
if (pending.length) {
const deviceId = await getDeviceId();
const groups = new Map<string, typeof pending>();
for (const row of pending) {
@ -129,14 +237,9 @@ export async function runSync(eventId?: string): Promise<{
groups.set(key, list);
}
let flushed = 0;
let lastError: string | null = null;
const touchedEvents = new Set<string>();
for (const [key, rows] of groups) {
const [eid, aid] = key.split('::');
touchedEvents.add(eid);
// Chunk 200
for (let i = 0; i < rows.length; i += 200) {
const chunk = rows.slice(i, i + 200);
const err = await flushGroup(
@ -146,29 +249,80 @@ export async function runSync(eventId?: string): Promise<{
clientOpId: r.clientOpId,
participantId: r.participantId,
checkedInAt: r.checkedInAt,
profile: r.profile,
action: r.action,
})),
deviceId,
!pull,
);
if (err) lastError = err;
else flushed += chunk.length;
}
}
const ts = new Date().toISOString();
for (const eid of touchedEvents) {
await setEventLastSyncedAt(eid, ts);
}
emit(lastError);
return lastError
? { ok: false, flushed, error: lastError }
: { ok: true, flushed };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Sync failed';
emit(msg);
return { ok: false, flushed: 0, error: msg };
} finally {
syncing = false;
emit(null);
}
let pulled = false;
if (pull) {
const pullResult = await pullSheets(opts);
for (const id of pullResult.eventIds) touchedEvents.add(id);
if (pullResult.error) {
lastError = lastError || pullResult.error;
}
if (pullResult.eventIds.length) {
pulled = true;
}
}
const ts = new Date().toISOString();
for (const eid of touchedEvents) {
await setEventLastSyncedAt(eid, ts);
}
emit(lastError);
return lastError
? { ok: false, flushed, pulled, error: lastError }
: { ok: true, flushed, pulled };
}
/**
* Flush pending outbox, optionally pull latest sheets.
* Overlapping calls are queued and merged (widest scope + pull wins).
*/
export function runSync(opts: SyncOpts = {}): Promise<SyncResult> {
if (running) {
queuedOpts = queuedOpts ? mergeSyncOpts(queuedOpts, opts) : { ...opts };
if (!queuedDeferred) queuedDeferred = createDeferred();
return queuedDeferred.promise;
}
running = true;
syncing = true;
emit(null);
return (async () => {
try {
const first = await executeSync(opts);
while (queuedOpts) {
const next = queuedOpts;
const deferred = queuedDeferred;
queuedOpts = null;
queuedDeferred = null;
const queuedResult = await executeSync(next);
deferred?.resolve(queuedResult);
}
return first;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Sync failed';
const fail: SyncResult = { ok: false, flushed: 0, pulled: false, error: msg };
queuedDeferred?.resolve(fail);
queuedOpts = null;
queuedDeferred = null;
emit(msg);
return fail;
} finally {
running = false;
syncing = false;
emit(null);
}
})();
}

View File

@ -17,6 +17,7 @@ export type OfflineSessionRow = {
name: string;
status: string;
allowDuplicateCheckIn: number;
allowAssignBlank: number;
};
export type OfflineParticipantRow = {
@ -33,6 +34,15 @@ export type OfflineParticipantRow = {
lastCheckedInAt: string | null;
};
export type AssignBlankProfile = {
firstName: string;
lastName?: string;
email?: string;
phone?: string;
};
export type OutboxAction = 'check_in' | 'undo';
export type OutboxRow = {
clientOpId: string;
eventId: string;
@ -44,6 +54,8 @@ export type OutboxRow = {
lastError: string | null;
createdAt: string;
updatedAt: string;
profile: AssignBlankProfile | null;
action: OutboxAction;
};
export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected';

View File

@ -0,0 +1,242 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { AttendanceCheckInResult } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal';
import type { RootStackParamList } from '../navigation/types';
import { performAppAssignBlank, performAppUndoCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext';
import { colors, spacing, typography } from '../theme';
import { AppIcon, PrimaryButton, TextField } from '../components/ui';
type Props = NativeStackScreenProps<RootStackParamList, 'AssignBlank'>;
export function AssignBlankScreen({ navigation, route }: Props) {
const {
eventId,
eventName,
attendanceId,
attendanceName,
participantId,
firstName: initialFirst,
lastName: initialLast,
email: initialEmail,
phone: initialPhone,
} = route.params;
const insets = useSafeAreaInsets();
const { refreshStatuses } = useOffline();
const [firstName, setFirstName] = useState(initialFirst || '');
const [lastName, setLastName] = useState(initialLast || '');
const [email, setEmail] = useState(initialEmail || '');
const [phone, setPhone] = useState(initialPhone || '');
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [undoLoading, setUndoLoading] = useState(false);
const save = async () => {
if (!firstName.trim()) {
setResult(null);
setResultError('First name is required');
setPendingSync(false);
setModalVisible(true);
return;
}
setSaving(true);
const outcome = await performAppAssignBlank({
eventId,
attendanceId,
attendanceName,
participantId,
profile: { firstName, lastName, email, phone },
});
setSaving(false);
if (!outcome.ok) {
setResult(null);
setResultError(outcome.error);
setPendingSync(false);
} else {
setResult(outcome.result);
setResultError(null);
setPendingSync(outcome.pendingSync);
refreshStatuses().catch(() => undefined);
}
setModalVisible(true);
};
const closeModal = () => {
if (undoLoading) return;
setModalVisible(false);
if (result && !resultError) {
navigation.goBack();
return;
}
setResult(null);
setResultError(null);
setPendingSync(false);
};
const handleUndo = async () => {
if (!result) return;
setUndoLoading(true);
const outcome = await performAppUndoCheckIn({
eventId,
attendanceId,
attendanceName,
participantId: result.participantId,
});
setUndoLoading(false);
if (!outcome.ok) {
Alert.alert('Undo failed', outcome.error);
return;
}
setModalVisible(false);
setResult(null);
setResultError(null);
setPendingSync(false);
refreshStatuses().catch(() => undefined);
navigation.goBack();
};
return (
<View style={styles.wrap}>
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<View style={styles.headerBg} />
<Pressable onPress={() => navigation.goBack()} hitSlop={10} style={styles.backBtn}>
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
<Text style={styles.backLabel} numberOfLines={1}>
{eventName}
</Text>
</Pressable>
<Text style={styles.headerTitle}>Assign a Blank</Text>
<Text style={styles.headerSubtitle}>{attendanceName}</Text>
</View>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={[
styles.form,
{ paddingBottom: Math.max(insets.bottom, 16) + 24 },
]}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.lead}>
Update this spare badge’s details, then save to check the person in.
</Text>
<TextField
label="First name"
value={firstName}
onChangeText={setFirstName}
autoCapitalize="words"
autoCorrect={false}
/>
<TextField
label="Last name"
value={lastName}
onChangeText={setLastName}
autoCapitalize="words"
autoCorrect={false}
/>
<TextField
label="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TextField
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
autoCorrect={false}
/>
<PrimaryButton
title="Save and check in"
loading={saving}
onPress={() => {
void save();
}}
/>
</ScrollView>
</KeyboardAvoidingView>
<CheckInResultModal
visible={modalVisible}
result={result}
error={resultError}
pendingSync={pendingSync}
onClose={closeModal}
onUndo={result ? handleUndo : undefined}
undoLoading={undoLoading}
/>
</View>
);
}
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background },
flex: { flex: 1 },
header: {
paddingHorizontal: spacing.xl,
paddingBottom: spacing.lg,
overflow: 'hidden',
},
headerBg: {
...StyleSheet.absoluteFill,
backgroundColor: colors.primary,
zIndex: 0,
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: spacing.md,
alignSelf: 'flex-start',
zIndex: 1,
},
backLabel: {
color: 'rgba(255,255,255,0.85)',
fontSize: 14,
fontWeight: '500',
},
headerTitle: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
zIndex: 1,
},
headerSubtitle: {
color: 'rgba(255,255,255,0.90)',
fontSize: 14,
zIndex: 1,
},
form: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
gap: 16,
},
lead: {
...typography.small,
color: colors.muted,
marginBottom: 4,
},
});

View File

@ -15,6 +15,7 @@ import { attendancesApi } from '../api/attendances';
import type { AttendanceRecord } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { useOffline } from '../offline/OfflineContext';
import { formatSyncAlert } from '../offline/syncEngine';
import {
dismissConflict,
getOfflineEvent,
@ -31,6 +32,13 @@ const SESSION_ICONS: AppIconName[] = ['business-outline', 'people-outline'];
const CHECKIN_GREEN = '#22C55E';
const PEOPLE_BLUE = '#60A5FA';
function formatLastSynced(iso?: string | null) {
if (!iso) return 'not yet';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'not yet';
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function SessionsHeader({
eventName,
onBack,
@ -39,6 +47,7 @@ function SessionsHeader({
pendingCount,
isOnline,
offlineReady,
lastSyncedAt,
}: {
eventName: string;
onBack: () => void;
@ -47,6 +56,7 @@ function SessionsHeader({
pendingCount: number;
isOnline: boolean;
offlineReady: boolean;
lastSyncedAt: string | null;
}) {
const insets = useSafeAreaInsets();
@ -85,7 +95,7 @@ function SessionsHeader({
<Text style={styles.headerSubtitle}>
{offlineReady
? isOnline
? 'Offline pack ready · Select a session'
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
: 'Working offline · Select a session'
: 'Select an attendance session'}
</Text>
@ -115,6 +125,41 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
const load = useCallback(async () => {
setError(null);
if (await getOfflineEvent(eventId)) {
const local = await listOfflineSessions(eventId);
if (!local.length) {
setError(
isOnline
? 'No active attendance sessions.'
: 'No offline data for this event. Go online and Enable Offline first.',
);
setSessions([]);
setFromCache(true);
return;
}
const mapped: AttendanceRecord[] = [];
for (const s of local) {
const participants = await listOfflineParticipants(s.id);
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
mapped.push({
_id: s.id,
name: s.name,
status: s.status as 'active' | 'archived',
settings: {
allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1,
allowAssignBlank: s.allowAssignBlank === 1,
},
uniqueParticipants: unique,
totalCheckedIn: total,
});
}
setSessions(mapped);
setFromCache(true);
return;
}
if (isOnline) {
const res = await attendancesApi.list(eventId);
if (!res.error && res.data) {
@ -123,64 +168,35 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
setFromCache(false);
return;
}
// fall through to cache on failure
if (!(await getOfflineEvent(eventId))) {
setError(res.error || 'Failed to load sessions');
setSessions([]);
setFromCache(false);
return;
}
}
const local = await listOfflineSessions(eventId);
if (!local.length) {
setError(
isOnline
? 'No active attendance sessions.'
: 'No offline data for this event. Go online and Enable Offline first.',
);
setError(res.error || 'Failed to load sessions');
setSessions([]);
setFromCache(false);
return;
}
const mapped: AttendanceRecord[] = [];
for (const s of local) {
const participants = await listOfflineParticipants(s.id);
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
mapped.push({
_id: s.id,
name: s.name,
status: s.status as 'active' | 'archived',
settings: { allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1 },
uniqueParticipants: unique,
totalCheckedIn: total,
});
}
setSessions(mapped);
setFromCache(true);
setError('No offline data for this event. Go online and Enable Offline first.');
setSessions([]);
setFromCache(false);
}, [eventId, isOnline]);
useEffect(() => {
const unsub = navigation.addListener('focus', () => {
load();
refreshStatuses();
});
(async () => {
setLoading(true);
await load();
await refreshStatuses();
setLoading(false);
})();
}, [load, refreshStatuses]);
return unsub;
}, [navigation, load, refreshStatuses]);
const handleSync = async () => {
const result = await syncNow(eventId);
const result = await syncNow({ eventId, pull: true });
await load();
if (!result.ok && result.error) {
Alert.alert('Sync', result.error);
} else if (result.flushed > 0) {
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
} else {
Alert.alert('Sync', 'Nothing pending to sync.');
}
Alert.alert('Sync', formatSyncAlert(result));
const conflicts = await listConflictOutbox(eventId);
if (conflicts.length) {
@ -218,6 +234,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
attendanceId: item._id,
attendanceName: item.name,
allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
allowAssignBlank: Boolean(item.settings?.allowAssignBlank),
})
}
>
@ -265,6 +282,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
pendingCount={status?.pendingCount ?? 0}
isOnline={isOnline}
offlineReady={offlineReady}
lastSyncedAt={status?.lastSyncedAt ?? null}
/>
{fromCache ? (

View File

@ -18,6 +18,7 @@ import type { CheckinsEvent } from '../api/types';
import { useAuth } from '../context/AuthContext';
import { downloadOfflinePack, refreshOfflinePack } from '../offline/download';
import { useOffline } from '../offline/OfflineContext';
import { formatSyncAlert } from '../offline/syncEngine';
import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos';
import type { RootStackParamList } from '../navigation/types';
import { colors, radii, shadows, spacing, typography } from '../theme';
@ -47,6 +48,7 @@ function EventsHeader({
syncing,
pendingTotal,
isOnline,
hasOfflinePacks,
}: {
userEmail?: string | null;
onLogout: () => void;
@ -54,6 +56,7 @@ function EventsHeader({
syncing: boolean;
pendingTotal: number;
isOnline: boolean;
hasOfflinePacks: boolean;
}) {
const insets = useSafeAreaInsets();
@ -74,7 +77,7 @@ function EventsHeader({
onPress={onSync}
hitSlop={10}
style={styles.syncBtn}
disabled={syncing || !isOnline}
disabled={syncing || !isOnline || !hasOfflinePacks}
>
{syncing ? (
<ActivityIndicator color="#fff" size="small" />
@ -191,14 +194,9 @@ export function EventsScreen({ navigation }: Props) {
};
const handleSync = async () => {
const result = await syncNow();
if (!result.ok && result.error) {
Alert.alert('Sync', result.error);
} else if (result.flushed > 0) {
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
} else {
Alert.alert('Sync', 'Nothing pending to sync.');
}
const result = await syncNow({ pull: true });
await load();
Alert.alert('Sync', formatSyncAlert(result));
};
const enableOffline = async (item: CheckinsEvent) => {
@ -285,6 +283,7 @@ export function EventsScreen({ navigation }: Props) {
};
const pendingTotal = Object.values(statusByEvent).reduce((n, s) => n + s.pendingCount, 0);
const hasOfflinePacks = Object.values(statusByEvent).some((s) => s.enabled);
const renderItem = ({ item }: { item: CheckinsEvent }) => {
const sessionCount = item.attendanceSessionCount;
@ -390,6 +389,7 @@ export function EventsScreen({ navigation }: Props) {
syncing={syncing}
pendingTotal={pendingTotal}
isOnline={isOnline}
hasOfflinePacks={hasOfflinePacks}
/>
{loading ? (

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
StyleSheet,
@ -13,9 +14,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal';
import { SwipeRow } from '../components/SwipeRow';
import type { RootStackParamList } from '../navigation/types';
import { performAppCheckIn } from '../offline/checkInService';
import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext';
import { formatSyncAlert } from '../offline/syncEngine';
import {
countPendingForSession,
isOfflinePackActive,
@ -51,18 +54,41 @@ function formatCheckInTime(iso?: string | null) {
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
}
function formatLastSynced(iso?: string | null) {
if (!iso) return 'not yet';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'not yet';
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function ParticipantsHeader({
eventName,
sessionName,
checkedInCount,
totalCount,
onlyCheckedIn,
onToggleCheckedIn,
onBack,
onSync,
syncing,
pendingCount,
isOnline,
offlineReady,
lastSyncedAt,
}: {
eventName: string;
sessionName: string;
checkedInCount: number;
totalCount: number;
onlyCheckedIn: boolean;
onToggleCheckedIn: () => void;
onBack: () => void;
onSync: () => void;
syncing: boolean;
pendingCount: number;
isOnline: boolean;
offlineReady: boolean;
lastSyncedAt: string | null;
}) {
const insets = useSafeAreaInsets();
@ -71,12 +97,31 @@ function ParticipantsHeader({
<View style={styles.headerBg} />
<View style={styles.headerCircle} />
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
<Text style={styles.backLabel} numberOfLines={1}>
{eventName}
</Text>
</Pressable>
<View style={styles.headerTopRow}>
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
<Text style={styles.backLabel} numberOfLines={1}>
{eventName}
</Text>
</Pressable>
{offlineReady ? (
<Pressable
onPress={onSync}
hitSlop={10}
style={styles.syncBtn}
disabled={syncing || !isOnline}
>
{syncing ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<AppIcon name="cloud-upload-outline" size={16} color="#fff" />
)}
<Text style={styles.syncText}>
Sync{pendingCount > 0 ? ` (${pendingCount})` : ''}
</Text>
</Pressable>
) : null}
</View>
<View style={styles.headerMain}>
<View style={styles.headerTextCol}>
@ -84,13 +129,29 @@ function ParticipantsHeader({
{sessionName}
</Text>
<Text style={styles.headerSubtitle}>
Attendance · {checkedInCount}/{totalCount} checked in
{offlineReady
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
: `Attendance · ${checkedInCount}/${totalCount} checked in`}
</Text>
</View>
<View style={styles.checkedBadge}>
<Text style={styles.checkedBadgeCount}>{checkedInCount}</Text>
<Text style={styles.checkedBadgeLabel}>checked in</Text>
</View>
<Pressable
onPress={onToggleCheckedIn}
accessibilityRole="button"
accessibilityState={{ selected: onlyCheckedIn }}
accessibilityLabel="Show only checked-in participants"
style={[styles.checkedBadge, onlyCheckedIn ? styles.checkedBadgeActive : null]}
>
<Text
style={[styles.checkedBadgeCount, onlyCheckedIn ? styles.checkedBadgeCountActive : null]}
>
{checkedInCount}
</Text>
<Text
style={[styles.checkedBadgeLabel, onlyCheckedIn ? styles.checkedBadgeLabelActive : null]}
>
checked in
</Text>
</Pressable>
</View>
</View>
);
@ -127,9 +188,10 @@ function SearchBar({
}
export function ParticipantsScreen({ navigation, route }: Props) {
const { eventId, eventName, attendanceId, attendanceName, allowDuplicate } = route.params;
const { eventId, eventName, attendanceId, attendanceName, allowDuplicate, allowAssignBlank } =
route.params;
const insets = useSafeAreaInsets();
const { isOnline, refreshStatuses } = useOffline();
const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
const [searchQ, setSearchQ] = useState('');
const [loading, setLoading] = useState(true);
@ -139,15 +201,26 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [undoLoading, setUndoLoading] = useState(false);
const [openSwipeId, setOpenSwipeId] = useState<string | null>(null);
const [fromCache, setFromCache] = useState(false);
const [sessionPending, setSessionPending] = useState(0);
const [onlyCheckedIn, setOnlyCheckedIn] = useState(false);
const status = statusByEvent[eventId];
const offlineReady = Boolean(status?.enabled);
useLayoutEffect(() => {
navigation.setOptions({ gestureEnabled: openSwipeId == null });
}, [navigation, openSwipeId]);
const load = useCallback(async () => {
setError(null);
const packActive = await isOfflinePackActive(eventId);
const pending = packActive ? await countPendingForSession(attendanceId) : 0;
setSessionPending(pending);
// Prefer local roster when offline, or when unsynced outbox would diverge from server.
if (packActive && (!isOnline || pending > 0)) {
if (packActive) {
const local = await listOfflineParticipants(attendanceId);
setAll(local);
setFromCache(true);
@ -161,12 +234,6 @@ export function ParticipantsScreen({ navigation, route }: Props) {
setFromCache(false);
return;
}
if (packActive) {
const local = await listOfflineParticipants(attendanceId);
setAll(local);
setFromCache(true);
return;
}
setError(res.error || 'Failed to load participants');
setAll([]);
setFromCache(false);
@ -191,8 +258,11 @@ export function ParticipantsScreen({ navigation, route }: Props) {
}, [navigation, load]);
const displayed = useMemo(
() => all.filter((p) => matchesParticipant(p, searchQ)),
[all, searchQ],
() =>
all.filter(
(p) => (!onlyCheckedIn || p.alreadyCheckedIn) && matchesParticipant(p, searchQ),
),
[all, searchQ, onlyCheckedIn],
);
const checkedInCount = useMemo(
@ -225,13 +295,73 @@ export function ParticipantsScreen({ navigation, route }: Props) {
await refreshStatuses();
};
const handleSync = async () => {
const outcome = await syncNow({ eventId, attendanceId, pull: true });
await load();
Alert.alert('Sync', formatSyncAlert(outcome));
};
const closeModal = () => {
if (undoLoading) return;
setModalVisible(false);
setResult(null);
setResultError(null);
setPendingSync(false);
};
const handleUndo = async () => {
if (!result) return;
setUndoLoading(true);
const outcome = await performAppUndoCheckIn({
eventId,
attendanceId,
attendanceName,
participantId: result.participantId,
});
setUndoLoading(false);
if (!outcome.ok) {
Alert.alert('Undo failed', outcome.error);
return;
}
setModalVisible(false);
setResult(null);
setResultError(null);
setPendingSync(false);
await load();
await refreshStatuses();
};
const undoFromList = async (item: AttendanceSearchParticipant) => {
setOpenSwipeId(null);
setBusyId(item._id);
const outcome = await performAppUndoCheckIn({
eventId,
attendanceId,
attendanceName,
participantId: item._id,
});
setBusyId(null);
if (!outcome.ok) {
Alert.alert('Undo failed', outcome.error);
return;
}
await load();
await refreshStatuses();
};
const confirmUncheck = (item: AttendanceSearchParticipant) => {
Alert.alert('Uncheck-in', `Remove last check-in for ${item.displayName}?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Uncheck-in',
style: 'destructive',
onPress: () => {
void undoFromList(item);
},
},
]);
};
const listBottomPad = Math.max(insets.bottom, 16) + 88;
const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => {
@ -241,65 +371,88 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—';
const checkInTime = formatCheckInTime(item.lastCheckedInAt);
return (
<Card style={styles.rowCard} padded>
<View style={styles.row}>
<View
const rowInner = (
<View style={styles.row}>
<View
style={[
styles.avatar,
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
]}
>
<Text
style={[
styles.avatar,
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
styles.avatarText,
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
]}
>
<Text
style={[
styles.avatarText,
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
]}
>
{getInitial(item.displayName)}
</Text>
</View>
{getInitial(item.displayName)}
</Text>
</View>
<View style={styles.rowText}>
<Text style={styles.name} numberOfLines={1}>
{item.displayName}
</Text>
<Text style={styles.meta} numberOfLines={1}>
{meta}
</Text>
{item.alreadyCheckedIn ? (
<View style={styles.checkedRow}>
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
<Text style={styles.checked}>
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
</Text>
</View>
) : null}
</View>
{showAction ? (
<Pressable
style={[
styles.checkBtn,
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
]}
disabled={busyId === item._id}
onPress={() => checkIn(item._id)}
>
{busyId === item._id ? (
<ActivityIndicator color="#fff" size="small" />
) : isAgain ? (
<View style={styles.checkBtnInner}>
<AppIcon name="refresh-outline" size={11} color="#fff" />
<Text style={styles.checkBtnText}>Again</Text>
</View>
) : (
<Text style={styles.checkBtnText}>Check In</Text>
)}
</Pressable>
<View style={styles.rowText}>
<Text style={styles.name} numberOfLines={1}>
{item.displayName}
</Text>
<Text style={styles.meta} numberOfLines={1}>
{meta}
</Text>
{item.alreadyCheckedIn ? (
<View style={styles.checkedRow}>
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
<Text style={styles.checked}>
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
</Text>
</View>
) : null}
</View>
</Card>
{showAction ? (
<Pressable
style={[
styles.checkBtn,
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
]}
disabled={busyId === item._id}
onPress={() => checkIn(item._id)}
>
{busyId === item._id ? (
<ActivityIndicator color="#fff" size="small" />
) : isAgain ? (
<View style={styles.checkBtnInner}>
<AppIcon name="refresh-outline" size={11} color="#fff" />
<Text style={styles.checkBtnText}>Again</Text>
</View>
) : (
<Text style={styles.checkBtnText}>Check In</Text>
)}
</Pressable>
) : null}
</View>
);
if (!item.alreadyCheckedIn) {
return (
<Card style={styles.rowCard} padded>
{rowInner}
</Card>
);
}
return (
<SwipeRow
enabled
open={openSwipeId === item._id}
onOpen={() => setOpenSwipeId(item._id)}
onClose={() => setOpenSwipeId((id) => (id === item._id ? null : id))}
actionLabel="Uncheck-in"
actionBusy={busyId === item._id}
onActionPress={() => confirmUncheck(item)}
style={styles.swipeRow}
>
<Card style={styles.swipeCard} padded>
{rowInner}
</Card>
</SwipeRow>
);
};
@ -310,7 +463,17 @@ export function ParticipantsScreen({ navigation, route }: Props) {
sessionName={attendanceName}
checkedInCount={checkedInCount}
totalCount={all.length}
onlyCheckedIn={onlyCheckedIn}
onToggleCheckedIn={() => setOnlyCheckedIn((v) => !v)}
onBack={() => navigation.goBack()}
onSync={() => {
handleSync();
}}
syncing={syncing}
pendingCount={sessionPending}
isOnline={isOnline}
offlineReady={offlineReady}
lastSyncedAt={status?.lastSyncedAt ?? null}
/>
<SearchBar value={searchQ} onChangeText={setSearchQ} />
@ -326,6 +489,25 @@ export function ParticipantsScreen({ navigation, route }: Props) {
<Text style={styles.countText}>
{displayed.length} participant{displayed.length === 1 ? '' : 's'}
</Text>
{allowAssignBlank ? (
<Pressable
style={styles.assignBtn}
onPress={() =>
navigation.navigate('QrScanner', {
eventId,
eventName,
attendanceId,
attendanceName,
allowDuplicate,
allowAssignBlank: true,
mode: 'assignBlank',
})
}
>
<AppIcon name="person-add-outline" size={14} color="#fff" />
<Text style={styles.assignBtnText}>Assign a Blank</Text>
</Pressable>
) : null}
</View>
{loading ? (
@ -355,9 +537,12 @@ export function ParticipantsScreen({ navigation, route }: Props) {
onPress={() =>
navigation.navigate('QrScanner', {
eventId,
eventName,
attendanceId,
attendanceName,
allowDuplicate,
allowAssignBlank: Boolean(allowAssignBlank),
mode: 'checkIn',
})
}
>
@ -371,6 +556,8 @@ export function ParticipantsScreen({ navigation, route }: Props) {
error={resultError}
pendingSync={pendingSync}
onClose={closeModal}
onUndo={result ? handleUndo : undefined}
undoLoading={undoLoading}
/>
</View>
);
@ -398,14 +585,35 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0,
},
headerTopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
zIndex: 1,
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: spacing.md,
alignSelf: 'flex-start',
maxWidth: '85%',
zIndex: 1,
flex: 1,
minWidth: 0,
marginRight: 12,
},
syncBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(255,255,255,0.18)',
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: radii.pill,
},
syncText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
backLabel: {
color: 'rgba(255,255,255,0.85)',
@ -438,6 +646,17 @@ const styles = StyleSheet.create({
paddingVertical: 8,
alignItems: 'center',
minWidth: 64,
borderWidth: 1.5,
borderColor: 'transparent',
},
checkedBadgeActive: {
backgroundColor: '#fff',
borderColor: '#fff',
shadowColor: '#fff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 14,
elevation: 14,
},
checkedBadgeCount: {
color: '#fff',
@ -445,10 +664,16 @@ const styles = StyleSheet.create({
fontWeight: '700',
lineHeight: 24,
},
checkedBadgeCountActive: {
color: colors.primaryDark,
},
checkedBadgeLabel: {
color: 'rgba(255,255,255,0.95)',
fontSize: 11,
},
checkedBadgeLabelActive: {
color: colors.primaryDark,
},
cacheBanner: {
flexDirection: 'row',
alignItems: 'center',
@ -487,16 +712,35 @@ const styles = StyleSheet.create({
paddingVertical: 0,
},
countStrip: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
backgroundColor: '#fff',
paddingHorizontal: spacing.lg,
paddingVertical: 10,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
countText: {
flex: 1,
fontSize: 12,
color: colors.muted,
},
assignBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.primaryDark,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 7,
},
assignBtnText: {
color: '#fff',
fontSize: 12,
fontWeight: '700',
},
list: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
@ -513,6 +757,14 @@ const styles = StyleSheet.create({
borderRadius: radii.xl,
borderWidth: 0,
},
swipeRow: {
marginBottom: 10,
},
swipeCard: {
marginBottom: 0,
borderRadius: radii.xl,
borderWidth: 0,
},
row: {
flexDirection: 'row',
alignItems: 'center',

View File

@ -1,6 +1,7 @@
import React, { useCallback, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
StyleSheet,
Text,
@ -15,11 +16,13 @@ import {
useCameraPermission,
useCodeScanner,
} from 'react-native-vision-camera';
import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal';
import type { RootStackParamList } from '../navigation/types';
import { performAppCheckIn } from '../offline/checkInService';
import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext';
import { getOfflineParticipant, isOfflinePackActive } from '../offline/repos';
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
import { colors, radii, spacing, typography } from '../theme';
import { PrimaryButton } from '../components/ui';
@ -112,7 +115,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
const headerTop = insets.top + spacing.md;
const [headerBottom, setHeaderBottom] = useState(headerTop + 96);
const scanTop = headerBottom + SCAN_GAP_BELOW_HEADER;
const { eventId, attendanceId, attendanceName } = route.params;
const { eventId, eventName, attendanceId, attendanceName, mode = 'checkIn' } = route.params;
const assignBlank = mode === 'assignBlank';
const { refreshStatuses } = useOffline();
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
@ -122,6 +126,30 @@ export function QrScannerScreen({ navigation, route }: Props) {
const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [undoLoading, setUndoLoading] = useState(false);
const goToAssignForm = useCallback(
(draft: {
participantId: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}) => {
navigation.replace('AssignBlank', {
eventId,
eventName,
attendanceId,
attendanceName,
participantId: draft.participantId,
firstName: draft.firstName,
lastName: draft.lastName,
email: draft.email,
phone: draft.phone,
});
},
[navigation, eventId, eventName, attendanceId, attendanceName],
);
const handleScan = useCallback(
async (raw: string) => {
@ -141,6 +169,82 @@ export function QrScannerScreen({ navigation, route }: Props) {
setResultError(null);
setPendingSync(false);
if (assignBlank) {
const packActive = await isOfflinePackActive(eventId);
let firstName = '';
let lastName = '';
let email = '';
let phone = '';
let found = false;
if (packActive) {
const local = await getOfflineParticipant(attendanceId, parsed.participantId);
if (local) {
found = true;
firstName = local.firstName || '';
lastName = local.lastName || '';
email = local.email || '';
phone = local.phone || '';
}
} else {
const preview = await attendancesApi.getParticipantPreview(
eventId,
attendanceId,
parsed.participantId,
);
if (!preview.error && preview.data) {
found = true;
firstName = preview.data.firstName || '';
lastName = preview.data.lastName || '';
email = preview.data.email || '';
phone = preview.data.phone || '';
}
}
setProcessing(false);
if (!found) {
setResult(null);
setResultError(
packActive
? 'Participant not in offline pack. Refresh offline data while online.'
: 'Participant not in this attendance list.',
);
setModalVisible(true);
return;
}
const openForm = () =>
goToAssignForm({
participantId: parsed.participantId,
firstName,
lastName,
email,
phone,
});
if (firstName.trim() || email.trim()) {
const label = [firstName, lastName].filter(Boolean).join(' ').trim() || email;
Alert.alert(
'This badge already has details',
`${label} is already named. Assign this blank anyway?`,
[
{
text: 'Cancel',
style: 'cancel',
onPress: () => {
scannedRef.current = false;
},
},
{ text: 'Continue', onPress: openForm },
],
);
return;
}
openForm();
return;
}
const outcome = await performAppCheckIn({
eventId,
attendanceId,
@ -161,7 +265,15 @@ export function QrScannerScreen({ navigation, route }: Props) {
}
setModalVisible(true);
},
[eventId, attendanceId, attendanceName, processing, refreshStatuses],
[
assignBlank,
eventId,
attendanceId,
attendanceName,
processing,
refreshStatuses,
goToAssignForm,
],
);
const codeScanner = useCodeScanner({
@ -173,6 +285,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
});
const closeModal = () => {
if (undoLoading) return;
setModalVisible(false);
setResult(null);
setResultError(null);
@ -183,6 +296,28 @@ export function QrScannerScreen({ navigation, route }: Props) {
}
};
const handleUndo = async () => {
if (!result) return;
setUndoLoading(true);
const outcome = await performAppUndoCheckIn({
eventId,
attendanceId,
attendanceName,
participantId: result.participantId,
});
setUndoLoading(false);
if (!outcome.ok) {
Alert.alert('Undo failed', outcome.error);
return;
}
setModalVisible(false);
setResult(null);
setResultError(null);
setPendingSync(false);
scannedRef.current = false;
refreshStatuses().catch(() => undefined);
};
if (!hasPermission) {
return (
<View style={styles.centered}>
@ -234,7 +369,9 @@ export function QrScannerScreen({ navigation, route }: Props) {
}}
>
<Text style={styles.title}>{attendanceName}</Text>
<Text style={styles.hint}>Point at participant badge QR</Text>
<Text style={styles.hint}>
{assignBlank ? 'Scan blank badge QR' : 'Point at participant badge QR'}
</Text>
{processing ? (
<ActivityIndicator color="#fff" size="large" style={styles.spinner} />
) : null}
@ -252,6 +389,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
error={resultError}
pendingSync={pendingSync}
onClose={closeModal}
onUndo={result && !assignBlank ? handleUndo : undefined}
undoLoading={undoLoading}
/>
</View>
);