Improve check-in UX and offline support.

Update attendance/QR flows, branding assets, and related React Native screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-06 13:05:00 +05:30
parent 153d552894
commit 38bfd5843c
33 changed files with 3491 additions and 560 deletions

14
App.tsx
View File

@ -1,13 +1,23 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Platform, StatusBar } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import Ionicons from 'react-native-vector-icons/Ionicons';
import { AuthProvider } from './src/context/AuthContext'; import { AuthProvider } from './src/context/AuthContext';
import { OfflineProvider } from './src/offline/OfflineContext';
import { RootNavigator } from './src/navigation/RootNavigator'; import { RootNavigator } from './src/navigation/RootNavigator';
function App() { function App() {
useEffect(() => {
void Ionicons.loadFont();
}, []);
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
{Platform.OS === 'ios' ? <StatusBar barStyle="light-content" /> : null}
<AuthProvider> <AuthProvider>
<RootNavigator /> <OfflineProvider>
<RootNavigator />
</OfflineProvider>
</AuthProvider> </AuthProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );

View File

@ -30,6 +30,16 @@ If the simulator still shows the old welcome screen, press **`r`** in the Metro
If Gradle reports `Could not find org.asyncstorage.shared_storage:storage-android:1.0.0`, the project’s `android/build.gradle` already adds the package’s `local_repo` Maven path. After `npm install`, run `npm run android` again. If Gradle reports `Could not find org.asyncstorage.shared_storage:storage-android:1.0.0`, the project’s `android/build.gradle` already adds the package’s `local_repo` Maven path. After `npm install`, run `npm run android` again.
## Offline mode
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.
Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`).
## Configuration ## Configuration
Copy `.env.example` to `.env` and set `API_BASE_URL`: Copy `.env.example` to `.env` and set `API_BASE_URL`:

View File

@ -1,4 +1,4 @@
{ {
"name": "EventBuzzCheckins", "name": "EventBuzzCheckins",
"displayName": "EventBuzzCheckins" "displayName": "EventBuzz Checkins"
} }

View File

@ -2,6 +2,7 @@
* @format * @format
*/ */
import 'react-native-get-random-values';
import { AppRegistry } from 'react-native'; import { AppRegistry } from 'react-native';
import App from './App'; import App from './App';
import { name as appName } from './app.json'; import { name as appName } from './app.json';

View File

@ -25,6 +25,28 @@ PODS:
- hermes-engine (250829098.0.10): - hermes-engine (250829098.0.10):
- hermes-engine/Pre-built (= 250829098.0.10) - hermes-engine/Pre-built (= 250829098.0.10)
- hermes-engine/Pre-built (250829098.0.10) - hermes-engine/Pre-built (250829098.0.10)
- op-sqlite (17.1.5):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RCTDeprecation (0.85.3) - RCTDeprecation (0.85.3)
- RCTRequired (0.85.3) - RCTRequired (0.85.3)
- RCTSwiftUI (0.85.3) - RCTSwiftUI (0.85.3)
@ -1448,6 +1470,50 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- react-native-get-random-values (2.0.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-netinfo (12.0.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context (5.8.0): - react-native-safe-area-context (5.8.0):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
@ -1949,6 +2015,28 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- RNVectorIcons (10.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- VisionCamera (4.7.3): - VisionCamera (4.7.3):
- VisionCamera/Core (= 4.7.3) - VisionCamera/Core (= 4.7.3)
- VisionCamera/React (= 4.7.3) - VisionCamera/React (= 4.7.3)
@ -1961,6 +2049,7 @@ DEPENDENCIES:
- "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- "op-sqlite (from `../node_modules/@op-engineering/op-sqlite`)"
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`)
@ -2000,6 +2089,8 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) - React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)
- react-native-config (from `../node_modules/react-native-config`) - react-native-config (from `../node_modules/react-native-config`)
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`)
@ -2036,6 +2127,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- VisionCamera (from `../node_modules/react-native-vision-camera`) - VisionCamera (from `../node_modules/react-native-vision-camera`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@ -2047,6 +2139,8 @@ EXTERNAL SOURCES:
hermes-engine: hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-v250829098.0.10 :tag: hermes-v250829098.0.10
op-sqlite:
:path: "../node_modules/@op-engineering/op-sqlite"
RCTDeprecation: RCTDeprecation:
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired: RCTRequired:
@ -2123,6 +2217,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
react-native-config: react-native-config:
:path: "../node_modules/react-native-config" :path: "../node_modules/react-native-config"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context" :path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple: React-NativeModulesApple:
@ -2195,92 +2293,98 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RNScreens: RNScreens:
:path: "../node_modules/react-native-screens" :path: "../node_modules/react-native-screens"
RNVectorIcons:
:path: "../node_modules/react-native-vector-icons"
VisionCamera: VisionCamera:
:path: "../node_modules/react-native-vision-camera" :path: "../node_modules/react-native-vision-camera"
Yoga: Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
AsyncStorage: fb007da3d53332311126c6ce64e23765a04ecbe7 AsyncStorage: 097bd39cbb74dbfdeda2395deafdc09ed84561e3
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
hermes-engine: b8f76204869359f8bed36636ea3d6e542f026963 hermes-engine: b8f76204869359f8bed36636ea3d6e542f026963
op-sqlite: 1cd6211ea2d1986b07b11a26c1eda3e51e53c260
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
RCTSwiftUIWrapper: 7b8a1ffba8994a8f955c563511bffb74b4681317 RCTSwiftUIWrapper: 966ca7f5f22ac0b2b2255fb09cffc381f5440b03
RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2 RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2
React: e2dc35338068bbd299c66f043ae0d7f25de8499e React: e2dc35338068bbd299c66f043ae0d7f25de8499e
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
React-Core: f90d375d3bab515ad00df30605ce1bf02e6db12f React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
React-Core-prebuilt: 10f1a4faa8ebc8f2af5b577db5abd3e89e66ffa8 React-Core-prebuilt: 10f1a4faa8ebc8f2af5b577db5abd3e89e66ffa8
React-CoreModules: da4f80202ef954bdfb926ca4c9ac5f685900aa5c React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
React-cxxreact: 1d1d2a28a5f10e4e2e7cf2bfd54dc9c92c68c672 React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
React-debug: 92944dc4d89f56d640e75498266cbde557a48189 React-debug: 92944dc4d89f56d640e75498266cbde557a48189
React-defaultsnativemodule: 172d2ef7d11c531c40ee74f3be1ac98d258a817f React-defaultsnativemodule: cd64bc09d7ca24112bbaf1b91edbbcf3d81ea7dc
React-domnativemodule: 5a60a88075a35e20fa5879c94e7aa24e5f4071d0 React-domnativemodule: dacf5bc055ae041039574f38b73a20b91e368774
React-Fabric: 5c1954e06c094623e46d51da2dc2c926621c03a5 React-Fabric: bb0baa33d91839631d315800eb23e9aaa4338a44
React-FabricComponents: f32494150868073accd5d52d46b30a0496626813 React-FabricComponents: c504d0b0e2f3054b2ba19af839f175cb361153c0
React-FabricImage: dfcd2826b10f05c19e5bfa68d4937c4401c129db React-FabricImage: 91eaea1cc58d25ae2596a9277bcfe028f92374c3
React-featureflags: 84bcfcb8f91f9475e437f3dd579399085a4af51e React-featureflags: 5ac0455da0af12ca79b40402e2f42c5c7556b638
React-featureflagsnativemodule: 83111c4ee188b8859aaa8643c1be640442098fef React-featureflagsnativemodule: df7da181b064f10f5959a7cd529b5aab3686ecb2
React-graphics: e0441bc695f5c682a3fb1b0fd317e10765700f12 React-graphics: d25b1195baf24c7918543f4aff9be89cc080906f
React-hermes: da795ff5d5816d8940fca927c46dcdd81d7e9ad6 React-hermes: 663286153a8ad6bf752b742654c766ff5e8991e7
React-idlecallbacksnativemodule: b6d79e1c9e335564e17d18e2649fe7524c4e74e4 React-idlecallbacksnativemodule: 0bd5392cb67f1ab25df736814b7c05213b1d3c68
React-ImageManager: 0e137d7231092ddaa236f3520b67b4aa833e7079 React-ImageManager: a03eed3e3d4222130dc0ad503a1a5f3aa89de746
React-intersectionobservernativemodule: 160b3c85bb5a3df2bf50e60619c0db50aadbef8c React-intersectionobservernativemodule: 5d0f1c3c7b30031b0f6f730ee52dd9d607e2c966
React-jserrorhandler: 619d382632f5ed166541c03f7ba7deee76fb1b16 React-jserrorhandler: d5d6e7e20c5a2d6e8607e18d31d8712d7de676f6
React-jsi: eac528e72d25146faa922ef1aad82d338addbb75 React-jsi: eb7cc4cffcf24796cc302d5b2bca0e92544139a9
React-jsiexecutor: 2d3000fdde95d0da451f8976cdf9018448756023 React-jsiexecutor: 65006f60e64c72c6b82f62ef6bd17c84846e73f8
React-jsinspector: 0377987f9635c64c5aaf72ec73aaf2b0b0da7744 React-jsinspector: 01e32e2247b2486117fbb143db7f7717ef462c6d
React-jsinspectorcdp: 9db641a9cd0dd5b693df27d2e665ac2540ddc336 React-jsinspectorcdp: f1cbb34ca41d188ba22efd9b663cf258a911f6cd
React-jsinspectornetwork: 61b00d0adfe6f175830660f1b98da576bc74eb0a React-jsinspectornetwork: f61acc94c881c41451f508abfe6efa748b956c21
React-jsinspectortracing: 0f8d4f2ebd7523aece78cbc926cd4b6f2fc227dc React-jsinspectortracing: 9395894d9bd4d17931b9afcf230c0e7f4cb3d674
React-jsitooling: 36f3854063d507bc4d4a01227ebf1b63d9e24592 React-jsitooling: 03ead841daa12a93b18479f5e400ceab3732d36d
React-jsitracing: cddf044c0c2847d3f5822a984018fd5596c1d448 React-jsitracing: 4ae61c79e14360d1c6ab566031c62c490da78439
React-logger: 57001aefabd79d885589d2f31a8be05ccc393eaa React-logger: bf149dea4343a9037b74bade36cced8b63f03f46
React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71 React-Mapbuffer: fec3e025f0ffba6b32cd2a1d7bbdee3e269aae90
React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21 React-microtasksnativemodule: ab33a818d339f5a1da308893c11b487be66121a8
React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741 React-mutationobservernativemodule: a42d1626651ccd7d0dc02a56e69d4ec77c248893
react-native-config: 93721be89c97d983d5a90e2f3e692c8ff1d95720 react-native-config: 6188f674e4f4b3bdfd43c22886fb04f3103793ff
react-native-safe-area-context: c1eb308f4b36372a4de4b3bdaa8ed695ec3dd461 react-native-get-random-values: 68792987aef40e8aa72dc448d97e009d1f440c88
React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56 react-native-netinfo: 2f906d5d9a639090ed1c079135d6485a95f749f1
React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e react-native-safe-area-context: fb5c8ee9f6dd62ef710611b3d370c501f42a4ac0
React-NativeModulesApple: deba264b03bd79c6bd61014fa30e40321b5e443a
React-networking: 35e6070b084f435429f85c5db40b4d5b38652fe9
React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e
React-perflogger: b2b0144e4ba8dd157c1e90f8ebb02d22edbd040d React-perflogger: ca04287f205086a1edb5c95882be7b6068458889
React-performancecdpmetrics: 5a715ec33f2dea48a93a70db37a78b4f51f9fd5a React-performancecdpmetrics: cf1d0a3178ccd59353cedcacbda421f40100a889
React-performancetimeline: c292077a6fbc7f423269b01aab0fb7cc48efe3c0 React-performancetimeline: c9771212e7a43032d6f8d5edfb58280d46a7ce1f
React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884 React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884
React-RCTAnimation: 1f9a58c7b74c25ea4ca6e6fbd05f37cc4919097a React-RCTAnimation: 343147a9cd68c93d0ca280799fedfc7102d76ce4
React-RCTAppDelegate: 856f82c57b47c1795009af585cfb7b87fe2d3b5a React-RCTAppDelegate: 5054754e92aaa9f8bfabe0f1022b84e46f3dcb57
React-RCTBlob: 1cb18861a19be0b4d7e44bed9315e791413399f9 React-RCTBlob: 8c7ae3422ca4e72bc64b7a0142fd730efc5d4dfb
React-RCTFabric: 081853d9efb9b38fa868c1ff3d60e48106fe2a24 React-RCTFabric: be458db054b206c4d8e4f20f666e75d5f2c2d420
React-RCTFBReactNativeSpec: cbc760c7a889bfce20746ca0037b97c10ea58665 React-RCTFBReactNativeSpec: 06db2e8d0f352d9fa23321aed1dd2cde25a3e83c
React-RCTImage: 0d94b22644ca87fcb778a8fa3ffbf990bc9028df React-RCTImage: 481457bca63e039eb997f7d16c7560472f49657e
React-RCTLinking: 881a0c6772dd05a14ab0edfea05dadb698ec8090 React-RCTLinking: 76cbb871240cec2dc5e7aa26c60f59e0ebbcf5a2
React-RCTNetwork: feb412861e3fff3426eb0961c2acdd96bee8dce1 React-RCTNetwork: e23a778225b7672e38545d3c5c24e1f4aad6d15f
React-RCTRuntime: c61b848ffadc0209fe643406370d58021bd3585d React-RCTRuntime: 93c830b3ab3f7b494bbe7ae7289784f8b07b3947
React-RCTSettings: b6e14b8764f807ebe9be2e7f0d72be83b359ac26 React-RCTSettings: 96196b535bef147381f96cc60ce9bda85d8be848
React-RCTText: 1ecd4f85ff609183ebec858cf94c0f27a9dec163 React-RCTText: 749ebbd1a999fd84d80f37002ea3bf597fcea6df
React-RCTVibration: 3611aa5cb59094632b3141d72c50cbb8ce3d5b08 React-RCTVibration: 5b41a7f274757c2928845981d970916ef9e4ca14
React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f
React-renderercss: d4a70898381431dcc07d6deba94a7eaef0cc9058 React-renderercss: 80eb778756fed511d6128fc005188ac9008b0baf
React-rendererdebug: ad92235a960983f69183e2e59e2bce21e72c80a0 React-rendererdebug: b46f338fb9d3f0bea6cf0621016c6c5a7a18e72e
React-RuntimeApple: 53a5b8fe60b044ec6fd4d254d66b7da69314a295 React-RuntimeApple: c494b2089fad4a0c553cf63c2bb265f7eca2285d
React-RuntimeCore: 97e125471c0b8313db2bbeb1e9dc001a5503e979 React-RuntimeCore: b0bb151c3e2b26c6309d45d05c54aa65a6e0c094
React-runtimeexecutor: 0ff42dcf1cc4bc7a89d29532a16a7d2d3849fb2f React-runtimeexecutor: 00b18635b6216a1708f6eb35dbadfd993ec91c7b
React-RuntimeHermes: da5a1b7e39f3db1a7b3303d75d4c5bb6cd7c0d49 React-RuntimeHermes: bb44c4c574ce1b9507cad2e6be015344d18b94a9
React-runtimescheduler: 0e24c80f0c8b6e822a33e4ad89e0ab555704eedb React-runtimescheduler: e1631e57209cb94b3efc29002b6a049cac3f6599
React-timing: f23e82ad46673716e34a786048414ab80f237594 React-timing: 356b88317ca60d373b0d94b6e7a71b0a572899f5
React-utils: 2851415c698da4b18331f9a445825d260fffa32c React-utils: ccc01da318979af773259c4f6cdb1876f6f86f1a
React-webperformancenativemodule: 9f29ed34f6f6c01dac688b95fd2dfcbccf3aed7a React-webperformancenativemodule: f8b97c2cb6cfa94e92a503c09ad6d491c50a1390
ReactAppDependencyProvider: a54c0c9b976766e1b6d5e2bb4d1ad0d15913e9e1 ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2
ReactCodegen: 9af5798e943781fd2318dab7b60f25337388047c ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014
ReactCommon: 3ec2a55999d296af90c24beb66c8a77dc660d3ef ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeDependencies: 8c3f5cf0bb3f36f1832b653f5689626379a12766 ReactNativeDependencies: 8c3f5cf0bb3f36f1832b653f5689626379a12766
RNScreens: b612be2401172d6666143db20a0f70f638c4619b RNScreens: 75d04ea58bd7a8e8ac60b7968d0382aa71631a7d
VisionCamera: 3ea10c46a5c612f5f89fb46a54bef4a0de8b58a7 RNVectorIcons: 97f26211c07d69e45b189f9dd0dbbe5e2d2b0b92
Yoga: 36fee8f1fca3f54a28c3d7f80e69f66d73d3af96 VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f
Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b
PODFILE CHECKSUM: 0943ebd85d20bed58095755f929cbce4d6306cad PODFILE CHECKSUM: 0943ebd85d20bed58095755f929cbce4d6306cad
COCOAPODS: 1.15.2 COCOAPODS: 1.16.2

156
package-lock.json generated
View File

@ -9,15 +9,20 @@
"version": "0.0.1", "version": "0.0.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@op-engineering/op-sqlite": "^17.1.5",
"@react-native-async-storage/async-storage": "^3.0.3", "@react-native-async-storage/async-storage": "^3.0.3",
"@react-native-community/netinfo": "^12.0.1",
"@react-navigation/native": "^7.2.4", "@react-navigation/native": "^7.2.4",
"@react-navigation/native-stack": "^7.15.1", "@react-navigation/native-stack": "^7.15.1",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-config": "^1.6.1", "react-native-config": "^1.6.1",
"react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "^5.5.2", "react-native-safe-area-context": "^5.5.2",
"react-native-screens": "^4.25.1", "react-native-screens": "^4.25.1",
"react-native-vision-camera": "4.7.3" "react-native-vector-icons": "^10.3.0",
"react-native-vision-camera": "4.7.3",
"uuid": "^14.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
@ -33,7 +38,9 @@
"@react-native/typescript-config": "0.85.3", "@react-native/typescript-config": "0.85.3",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"@types/uuid": "^10.0.0",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"prettier": "2.8.8", "prettier": "2.8.8",
@ -2770,6 +2777,26 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@op-engineering/op-sqlite": {
"version": "17.1.5",
"resolved": "https://registry.npmjs.org/@op-engineering/op-sqlite/-/op-sqlite-17.1.5.tgz",
"integrity": "sha512-Al9YjK9EVYr1zLVLffMZkWHNfGScxe1B1NdmeKZ+pdk8bOX8wdE1du73+sk8humoR90OL+xOgMhdOteSQVubEw==",
"license": "MIT",
"workspaces": [
"example",
"node"
],
"peerDependencies": {
"@sqlite.org/sqlite-wasm": "*",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"@sqlite.org/sqlite-wasm": {
"optional": true
}
}
},
"node_modules/@react-native-async-storage/async-storage": { "node_modules/@react-native-async-storage/async-storage": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.3.tgz",
@ -3016,6 +3043,16 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@react-native-community/netinfo": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz",
"integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": ">=0.59"
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.85.3", "version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz",
@ -3638,6 +3675,27 @@
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-native": {
"version": "0.70.19",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz",
"integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-native-vector-icons": {
"version": "6.4.18",
"resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz",
"integrity": "sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@types/react-native": "^0.70"
}
},
"node_modules/@types/react-test-renderer": { "node_modules/@types/react-test-renderer": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
@ -3655,6 +3713,13 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -6377,6 +6442,12 @@
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/fast-base64-decode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
"integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -9597,7 +9668,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -10133,7 +10203,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -10145,7 +10214,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/punycode": { "node_modules/punycode": {
@ -10397,6 +10465,18 @@
} }
} }
}, },
"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",
"integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==",
"license": "MIT",
"dependencies": {
"fast-base64-decode": "^1.0.0"
},
"peerDependencies": {
"react-native": ">=0.81"
}
},
"node_modules/react-native-safe-area-context": { "node_modules/react-native-safe-area-context": {
"version": "5.8.0", "version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
@ -10421,6 +10501,61 @@
"react-native": ">=0.82.0" "react-native": ">=0.82.0"
} }
}, },
"node_modules/react-native-vector-icons": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",
"integrity": "sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==",
"deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2",
"yargs": "^16.1.1"
},
"bin": {
"fa-upgrade.sh": "bin/fa-upgrade.sh",
"fa5-upgrade": "bin/fa5-upgrade.sh",
"fa6-upgrade": "bin/fa6-upgrade.sh",
"generate-icon": "bin/generate-icon.js"
}
},
"node_modules/react-native-vector-icons/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-vision-camera": { "node_modules/react-native-vision-camera": {
"version": "4.7.3", "version": "4.7.3",
"resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz", "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz",
@ -12069,6 +12204,19 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View File

@ -4,6 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
"android:share-apk": "cd android && ./gradlew assembleDebug -PreactNativeArchitectures=arm64-v8a",
"android:reset-build": "rm -rf android/app/.cxx android/app/build android/build && cd android && ./gradlew assembleDebug -PreactNativeArchitectures=arm64-v8a",
"ios": "react-native run-ios", "ios": "react-native run-ios",
"lint": "eslint .", "lint": "eslint .",
"postinstall": "node scripts/patch-metro-index.js", "postinstall": "node scripts/patch-metro-index.js",
@ -11,15 +13,20 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@op-engineering/op-sqlite": "^17.1.5",
"@react-native-async-storage/async-storage": "^3.0.3", "@react-native-async-storage/async-storage": "^3.0.3",
"@react-native-community/netinfo": "^12.0.1",
"@react-navigation/native": "^7.2.4", "@react-navigation/native": "^7.2.4",
"@react-navigation/native-stack": "^7.15.1", "@react-navigation/native-stack": "^7.15.1",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-config": "^1.6.1", "react-native-config": "^1.6.1",
"react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "^5.5.2", "react-native-safe-area-context": "^5.5.2",
"react-native-screens": "^4.25.1", "react-native-screens": "^4.25.1",
"react-native-vision-camera": "4.7.3" "react-native-vector-icons": "^10.3.0",
"react-native-vision-camera": "4.7.3",
"uuid": "^14.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
@ -35,7 +42,9 @@
"@react-native/typescript-config": "0.85.3", "@react-native/typescript-config": "0.85.3",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"@types/uuid": "^10.0.0",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"prettier": "2.8.8", "prettier": "2.8.8",

View File

@ -3,6 +3,8 @@ import type {
AttendanceRecord, AttendanceRecord,
AttendanceSearchParticipant, AttendanceSearchParticipant,
AttendanceCheckInResult, AttendanceCheckInResult,
OfflineSyncOperation,
OfflineSyncResponse,
ParticipantPreview, ParticipantPreview,
} from './types'; } from './types';
@ -30,4 +32,15 @@ export const attendancesApi = {
{ participantId }, { participantId },
); );
}, },
syncCheckIns(
eventId: string,
attendanceId: string,
body: { deviceId?: string; operations: OfflineSyncOperation[] },
) {
return api.post<OfflineSyncResponse>(
`/events/${eventId}/attendances/${attendanceId}/check-ins/sync`,
body,
);
},
}; };

View File

@ -74,6 +74,24 @@ export interface AttendanceCheckInResult {
alreadyCheckedIn: boolean; alreadyCheckedIn: boolean;
} }
export interface OfflineSyncOperation {
clientOpId: string;
participantId: string;
checkedInAt: string;
}
export interface OfflineSyncOpResult {
clientOpId: string;
status: 'applied' | 'duplicate' | 'conflict' | 'rejected';
checkInCount?: number;
lastCheckedInAt?: string | null;
message?: string;
}
export interface OfflineSyncResponse {
results: OfflineSyncOpResult[];
}
export interface ParticipantPreview { export interface ParticipantPreview {
_id: string; _id: string;
displayName: string; displayName: string;

BIN
src/assets/app-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,47 +1,91 @@
import React from 'react'; import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AttendanceCheckInResult } from '../api/types'; import type { AttendanceCheckInResult } from '../api/types';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, spacing, typography } from '../theme';
import { Card, PrimaryButton } from './ui'; import { AppIcon, PrimaryButton } from './ui';
type Props = { type Props = {
visible: boolean; visible: boolean;
result: AttendanceCheckInResult | null; result: AttendanceCheckInResult | null;
error: string | null; error: string | null;
pendingSync?: boolean;
onClose: () => void; onClose: () => void;
}; };
export function CheckInResultModal({ visible, result, error, onClose }: Props) { const CHECKIN_GREEN = '#16A34A';
const success = Boolean(result && !error); const PENDING_AMBER = '#D97706';
export function CheckInResultModal({
visible,
result,
error,
pendingSync = false,
onClose,
}: Props) {
const insets = useSafeAreaInsets();
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.overlay} onPress={onClose}> <Pressable style={styles.overlay} onPress={onClose}>
<Pressable onPress={(e) => e.stopPropagation()}> <Pressable
<Card style={styles.card} padded> style={[styles.sheet, { paddingBottom: Math.max(insets.bottom, spacing.lg) }]}
onPress={(e) => e.stopPropagation()}
>
{error ? ( {error ? (
<> <>
<View style={styles.errorIconWrap}>
<AppIcon name="close-circle-outline" size={36} color={colors.error} />
</View>
<Text style={styles.titleError}>Check-in failed</Text> <Text style={styles.titleError}>Check-in failed</Text>
<Text style={styles.body}>{error}</Text> <Text style={styles.body}>{error}</Text>
</> </>
) : result ? ( ) : result ? (
<> <>
<Text style={styles.titleSuccess}>Checked in</Text> <View style={styles.successIconWrap}>
<Text style={styles.name}>{result.displayName}</Text> <AppIcon name="checkmark-circle-outline" size={36} color="#fff" />
{result.email ? <Text style={styles.meta}>{result.email}</Text> : null} </View>
{result.phone ? <Text style={styles.meta}>{result.phone}</Text> : null} <Text style={styles.titleSuccess}>Checked In!</Text>
<Text style={styles.meta}>
Session: {result.attendanceName} <View style={styles.detailCard}>
</Text> <Text style={styles.name}>{result.displayName}</Text>
{result.lastCheckedInAt ? ( {result.email ? (
<Text style={styles.meta}> <View style={styles.detailRow}>
{new Date(result.lastCheckedInAt).toLocaleString()} <AppIcon name="mail-outline" size={16} color={colors.primary} />
</Text> <Text style={styles.detailText}>{result.email}</Text>
) : null} </View>
) : null}
{result.phone ? (
<View style={styles.detailRow}>
<AppIcon name="call-outline" size={16} color={colors.primary} />
<Text style={styles.detailText}>{result.phone}</Text>
</View>
) : null}
<View style={styles.detailRow}>
<AppIcon name="qr-code-outline" size={16} color={colors.primary} />
<Text style={styles.detailText}>Session: {result.attendanceName}</Text>
</View>
{result.lastCheckedInAt ? (
<View style={styles.detailRow}>
<AppIcon name="time-outline" size={16} color={colors.primary} />
<Text style={styles.detailText}>
{new Date(result.lastCheckedInAt).toLocaleString()}
</Text>
</View>
) : null}
{pendingSync ? (
<View style={styles.pendingBanner}>
<AppIcon name="cloud-offline-outline" size={16} color={PENDING_AMBER} />
<Text style={styles.pendingText}>
Saved offline — will sync when you are back online
</Text>
</View>
) : null}
</View>
</> </>
) : null} ) : null}
<PrimaryButton title="Done" onPress={onClose} />
</Card> <PrimaryButton title="Done" onPress={onClose} style={styles.doneBtn} />
</Pressable> </Pressable>
</Pressable> </Pressable>
</Modal> </Modal>
@ -51,37 +95,89 @@ export function CheckInResultModal({ visible, result, error, onClose }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0,0,0,0.45)', backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center', justifyContent: 'flex-end',
padding: 24,
}, },
card: { sheet: {
borderRadius: radii.xl, backgroundColor: '#fff',
borderWidth: 0, borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: spacing.xl,
paddingTop: spacing.xl,
},
successIconWrap: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: CHECKIN_GREEN,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: spacing.md,
},
errorIconWrap: {
alignSelf: 'center',
marginBottom: spacing.md,
}, },
titleSuccess: { titleSuccess: {
...typography.h2, fontSize: 20,
color: colors.success, fontWeight: '700',
marginBottom: 12, color: CHECKIN_GREEN,
textAlign: 'center',
marginBottom: spacing.lg,
}, },
titleError: { titleError: {
...typography.h2, ...typography.h2,
color: colors.error, color: colors.error,
marginBottom: 12, textAlign: 'center',
marginBottom: spacing.md,
}, },
name: { name: {
...typography.h3, fontSize: 17,
fontWeight: '700',
color: colors.text, color: colors.text,
marginBottom: spacing.md,
},
detailCard: {
backgroundColor: '#F9FAFB',
borderRadius: radii.xl,
padding: spacing.lg,
marginBottom: spacing.lg,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: 8, marginBottom: 8,
}, },
detailText: {
flex: 1,
...typography.small,
color: colors.muted,
fontSize: 14,
},
body: { body: {
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
marginBottom: 16, textAlign: 'center',
marginBottom: spacing.lg,
}, },
meta: { pendingBanner: {
...typography.small, flexDirection: 'row',
color: colors.muted, alignItems: 'flex-start',
marginBottom: 4, gap: 8,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
},
pendingText: {
flex: 1,
fontSize: 13,
color: PENDING_AMBER,
fontWeight: '600',
},
doneBtn: {
marginTop: spacing.sm,
}, },
}); });

View File

@ -0,0 +1,41 @@
import React from 'react';
import type { StyleProp, TextStyle } from 'react-native';
import Ionicons from 'react-native-vector-icons/Ionicons';
import { colors } from '../../theme';
export type AppIconName =
| 'arrow-back'
| 'business-outline'
| 'calendar-outline'
| 'call-outline'
| 'checkmark-circle-outline'
| 'chevron-forward-outline'
| 'close-circle-outline'
| 'cloud-done-outline'
| 'cloud-download-outline'
| 'cloud-offline-outline'
| 'cloud-upload-outline'
| 'enter-outline'
| 'eye-off-outline'
| 'eye-outline'
| 'location-outline'
| 'lock-closed-outline'
| 'log-out-outline'
| 'mail-outline'
| 'people-outline'
| 'person-outline'
| 'qr-code-outline'
| 'refresh-outline'
| 'search-outline'
| 'time-outline';
type Props = {
name: AppIconName;
size?: number;
color?: string;
style?: StyleProp<TextStyle>;
};
export function AppIcon({ name, size = 18, color = colors.muted, style }: Props) {
return <Ionicons name={name} size={size} color={color} style={style} />;
}

View File

@ -1,24 +1,33 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { ViewStyle } from 'react-native'; import type { ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing, typography } from '../../theme'; import { colors, spacing, typography } from '../../theme';
type Props = { type Props = {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
onBack?: () => void;
right?: React.ReactNode; right?: React.ReactNode;
bottom?: React.ReactNode; bottom?: React.ReactNode;
style?: ViewStyle | ViewStyle[]; style?: ViewStyle | ViewStyle[];
}; };
export function GradientHeader({ title, subtitle, right, bottom, style }: Props) { export function GradientHeader({ title, subtitle, onBack, right, bottom, style }: Props) {
const insets = useSafeAreaInsets();
return ( return (
<View style={[styles.wrap, style]}> <View style={[styles.wrap, { paddingTop: insets.top + spacing.md }, style]}>
{/* Faux “gradient”: solid orange with decorative circles. Replace with LinearGradient once deps install. */}
<View style={styles.bg} /> <View style={styles.bg} />
<View style={styles.circleA} /> <View style={styles.circleA} />
<View style={styles.circleB} /> <View style={styles.circleB} />
{onBack ? (
<Pressable onPress={onBack} hitSlop={12} style={styles.backRow}>
<Text style={styles.backText}>‹ Back</Text>
</Pressable>
) : null}
<View style={styles.topRow}> <View style={styles.topRow}>
<View style={styles.textCol}> <View style={styles.textCol}>
{title ? <Text style={styles.title}>{title}</Text> : null} {title ? <Text style={styles.title}>{title}</Text> : null}
@ -34,14 +43,14 @@ export function GradientHeader({ title, subtitle, right, bottom, style }: Props)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrap: { wrap: {
paddingTop: 52,
paddingBottom: 18, paddingBottom: 18,
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
overflow: 'hidden', overflow: 'hidden',
}, },
bg: { bg: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: colors.primary, backgroundColor: colors.primary,
zIndex: 0,
}, },
circleA: { circleA: {
position: 'absolute', position: 'absolute',
@ -51,6 +60,7 @@ const styles = StyleSheet.create({
right: -40, right: -40,
top: -40, top: -40,
backgroundColor: 'rgba(255,255,255,0.12)', backgroundColor: 'rgba(255,255,255,0.12)',
zIndex: 0,
}, },
circleB: { circleB: {
position: 'absolute', position: 'absolute',
@ -60,12 +70,24 @@ const styles = StyleSheet.create({
left: -24, left: -24,
bottom: -32, bottom: -32,
backgroundColor: 'rgba(255,255,255,0.10)', backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0,
},
backRow: {
marginBottom: 10,
alignSelf: 'flex-start',
zIndex: 1,
},
backText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 16,
fontWeight: '600',
}, },
topRow: { topRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 12, gap: 12,
zIndex: 1,
}, },
textCol: { flex: 1 }, textCol: { flex: 1 },
title: { title: {
@ -77,7 +99,6 @@ const styles = StyleSheet.create({
...typography.small, ...typography.small,
color: 'rgba(255,255,255,0.75)', color: 'rgba(255,255,255,0.75)',
}, },
right: { alignItems: 'flex-end' }, right: { alignItems: 'flex-end', zIndex: 1 },
bottom: { marginTop: 14 }, bottom: { marginTop: 14, zIndex: 1 },
}); });

View File

@ -23,9 +23,7 @@ export function PrimaryButton({ title, loading, disabled, style, ...props }: Pro
style, style,
]} ]}
> >
{/* Faux “gradient”: solid base + darker highlight strip. Replace with LinearGradient once deps install. */}
<View style={styles.bg} /> <View style={styles.bg} />
<View style={styles.highlight} />
<View style={styles.content}> <View style={styles.content}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.text}>{title}</Text>} {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.text}>{title}</Text>}
</View> </View>
@ -42,18 +40,9 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
bg: { bg: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: colors.primary, backgroundColor: colors.primary,
}, },
highlight: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '55%',
backgroundColor: colors.primaryDark,
opacity: 0.35,
},
content: { content: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native'; import { StyleSheet, Text, TextInput, View } from 'react-native';
import type { TextInputProps, ViewStyle } from 'react-native'; import type { TextInputProps, ViewStyle } from 'react-native';
import { colors, radii, typography } from '../../theme'; import { colors, radii, typography } from '../../theme';
@ -10,22 +10,36 @@ type Props = TextInputProps & {
containerStyle?: ViewStyle | ViewStyle[]; containerStyle?: ViewStyle | ViewStyle[];
}; };
export function TextField({ label, left, right, containerStyle, style, ...props }: Props) { export const TextField = React.forwardRef<TextInput, Props>(function TextField(
{ label, left, right, containerStyle, style, onFocus, onBlur, ...props },
ref,
) {
const [focused, setFocused] = useState(false);
return ( return (
<View style={containerStyle}> <View style={containerStyle}>
{label ? <Text style={styles.label}>{label}</Text> : null} {label ? <Text style={[styles.label, focused && styles.labelFocused]}>{label}</Text> : null}
<View style={styles.row}> <View style={[styles.row, focused && styles.rowFocused]}>
{left ? <View style={styles.side}>{left}</View> : null} {left ? <View style={styles.side}>{left}</View> : null}
<TextInput <TextInput
ref={ref}
{...props} {...props}
style={[styles.input, style]} onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
style={[styles.input, focused && styles.inputFocused, style]}
placeholderTextColor={colors.muted} placeholderTextColor={colors.muted}
/> />
{right ? <View style={styles.side}>{right}</View> : null} {right ? <View style={styles.side}>{right}</View> : null}
</View> </View>
</View> </View>
); );
} });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
label: { label: {
@ -33,6 +47,9 @@ const styles = StyleSheet.create({
color: colors.muted, color: colors.muted,
marginBottom: 8, marginBottom: 8,
}, },
labelFocused: {
color: colors.primaryDark,
},
row: { row: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -40,10 +57,14 @@ const styles = StyleSheet.create({
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
borderRadius: radii.md, borderRadius: radii.md,
borderWidth: 1, borderWidth: 2,
borderColor: colors.border, borderColor: colors.border,
backgroundColor: '#F9FAFB', backgroundColor: '#F9FAFB',
}, },
rowFocused: {
borderColor: colors.primary,
backgroundColor: '#FFFFFF',
},
side: { alignItems: 'center', justifyContent: 'center' }, side: { alignItems: 'center', justifyContent: 'center' },
input: { input: {
flex: 1, flex: 1,
@ -51,5 +72,7 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
paddingVertical: 0, paddingVertical: 0,
}, },
inputFocused: {
color: colors.text,
},
}); });

View File

@ -1,3 +1,4 @@
export * from './AppIcon';
export * from './Card'; export * from './Card';
export * from './PrimaryButton'; export * from './PrimaryButton';
export * from './TextField'; export * from './TextField';

View File

@ -1,6 +1,6 @@
import React from 'react'; import React, { useEffect } from 'react';
import { ActivityIndicator, View } from 'react-native'; import { ActivityIndicator, Platform, StatusBar, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native'; import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { LoginScreen } from '../screens/LoginScreen'; import { LoginScreen } from '../screens/LoginScreen';
@ -14,15 +14,60 @@ import { colors } from '../theme';
import { getAccessToken } from '../api/client'; import { getAccessToken } from '../api/client';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
/** Android only — iOS uses <StatusBar> in App.tsx to avoid react-native-screens plist assertion. */
const androidOrangeStatusBar =
Platform.OS === 'android'
? {
statusBarBackgroundColor: colors.primary,
statusBarStyle: 'light' as const,
}
: {};
/** iOS 26+ scroll edge effects draw a white veil over custom headers and buttons. */
const stackScreenOptions = {
headerShown: false as const,
contentStyle: { backgroundColor: colors.background },
...androidOrangeStatusBar,
...(Platform.OS === 'ios'
? {
scrollEdgeEffects: {
top: 'hidden' as const,
bottom: 'hidden' as const,
left: 'hidden' as const,
right: 'hidden' as const,
},
}
: {}),
};
export function RootNavigator() { export function RootNavigator() {
const { loading, user } = useAuth(); const { loading, user } = useAuth();
const hasToken = Boolean(getAccessToken()); const hasToken = Boolean(getAccessToken());
useEffect(() => {
if (loading || user || hasToken) return;
if (navigationRef.isReady()) {
navigationRef.reset({
index: 0,
routes: [{ name: 'Login' }],
});
}
}, [loading, user, hasToken]);
if (loading) { if (loading) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <View
<ActivityIndicator size="large" color={colors.primary} /> style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.primary,
}}
>
<StatusBar barStyle="light-content" backgroundColor={colors.primary} />
<ActivityIndicator size="large" color="#fff" />
</View> </View>
); );
} }
@ -30,44 +75,31 @@ export function RootNavigator() {
const initialRoute = user || hasToken ? 'Events' : 'Login'; const initialRoute = user || hasToken ? 'Events' : 'Login';
return ( return (
<NavigationContainer> <NavigationContainer ref={navigationRef}>
<Stack.Navigator <Stack.Navigator initialRouteName={initialRoute} screenOptions={stackScreenOptions}>
initialRouteName={initialRoute}
screenOptions={{
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.primaryDark,
headerTitleStyle: { fontWeight: '700', color: colors.text },
}}
>
<Stack.Screen <Stack.Screen
name="Login" name="Login"
component={LoginScreen} component={LoginScreen}
options={{ headerShown: false }} options={{
/> contentStyle: { backgroundColor: colors.primary },
<Stack.Screen ...androidOrangeStatusBar,
name="Login2FA" }}
component={Login2FAScreen}
options={{ title: '2FA', headerShown: false }}
/>
<Stack.Screen
name="Events"
component={EventsScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="AttendanceSessions"
component={AttendanceSessionsScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Participants"
component={ParticipantsScreen}
options={{ headerShown: false }}
/> />
<Stack.Screen name="Login2FA" component={Login2FAScreen} />
<Stack.Screen name="Events" component={EventsScreen} />
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
<Stack.Screen name="Participants" component={ParticipantsScreen} />
<Stack.Screen <Stack.Screen
name="QrScanner" name="QrScanner"
component={QrScannerScreen} component={QrScannerScreen}
options={{ title: 'Scan QR', headerShown: false }} options={
Platform.OS === 'android'
? {
statusBarBackgroundColor: '#000000',
statusBarStyle: 'light' as const,
}
: undefined
}
/> />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>

View File

@ -0,0 +1,122 @@
import NetInfo from '@react-native-community/netinfo';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { getDb } from './db';
import { runSync, subscribeSyncEngine } from './syncEngine';
import type { EventOfflineStatus } from './types';
import { getEventOfflineStatus, listOfflineEventIds } from './repos';
type OfflineContextValue = {
ready: boolean;
isOnline: boolean;
syncing: boolean;
lastSyncError: string | null;
statusByEvent: Record<string, EventOfflineStatus>;
refreshStatuses: () => Promise<void>;
syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>;
};
const OfflineContext = createContext<OfflineContextValue | null>(null);
export function OfflineProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [isOnline, setIsOnline] = useState(true);
const [syncing, setSyncing] = useState(false);
const [lastSyncError, setLastSyncError] = useState<string | null>(null);
const [statusByEvent, setStatusByEvent] = useState<Record<string, EventOfflineStatus>>({});
const refreshStatuses = useCallback(async () => {
const ids = await listOfflineEventIds();
const next: Record<string, EventOfflineStatus> = {};
await Promise.all(
ids.map(async (id) => {
next[id] = await getEventOfflineStatus(id);
}),
);
setStatusByEvent(next);
}, []);
const syncNow = useCallback(
async (eventId?: string) => {
const result = await runSync(eventId);
await refreshStatuses();
return result;
},
[refreshStatuses],
);
useEffect(() => {
let mounted = true;
(async () => {
try {
await getDb();
if (!mounted) return;
setReady(true);
await refreshStatuses();
} catch {
if (mounted) setReady(true);
}
})();
return () => {
mounted = false;
};
}, [refreshStatuses]);
useEffect(() => {
return subscribeSyncEngine(({ syncing: s, lastError }) => {
setSyncing(s);
if (lastError) setLastSyncError(lastError);
});
}, []);
useEffect(() => {
const unsub = NetInfo.addEventListener((state) => {
const online = Boolean(state.isConnected && state.isInternetReachable !== false);
setIsOnline(online);
if (online) {
syncNow().catch(() => undefined);
}
});
return () => unsub();
}, [syncNow]);
useEffect(() => {
const onAppState = (next: AppStateStatus) => {
if (next === 'active') {
syncNow().catch(() => undefined);
}
};
const sub = AppState.addEventListener('change', onAppState);
return () => sub.remove();
}, [syncNow]);
const value = useMemo(
() => ({
ready,
isOnline,
syncing,
lastSyncError,
statusByEvent,
refreshStatuses,
syncNow,
}),
[ready, isOnline, syncing, lastSyncError, statusByEvent, refreshStatuses, syncNow],
);
return <OfflineContext.Provider value={value}>{children}</OfflineContext.Provider>;
}
export function useOffline(): OfflineContextValue {
const ctx = useContext(OfflineContext);
if (!ctx) {
throw new Error('useOffline must be used within OfflineProvider');
}
return ctx;
}

View File

@ -0,0 +1,126 @@
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 { getDeviceId } from './deviceId';
import {
applyLocalCheckIn,
countPendingForSession,
getOfflineParticipant,
getOfflineSession,
insertOutbox,
isOfflinePackActive,
updateParticipantFromSync,
} from './repos';
export type CheckInServiceResult =
| { ok: true; result: AttendanceCheckInResult; pendingSync: boolean }
| { ok: false; error: string };
async function isOnline(): Promise<boolean> {
const state = await NetInfo.fetch();
return Boolean(state.isConnected && state.isInternetReachable !== false);
}
async function queueLocalCheckIn(opts: {
eventId: string;
attendanceId: string;
attendanceName: string;
participantId: string;
}): Promise<CheckInServiceResult> {
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.',
};
}
const allowDuplicate = session.allowDuplicateCheckIn === 1;
if (participant.alreadyCheckedIn === 1 && !allowDuplicate) {
return { ok: false, error: 'Participant already checked in' };
}
const checkedInAt = new Date().toISOString();
const clientOpId = uuidv4();
const deviceId = await getDeviceId();
await insertOutbox({
clientOpId,
eventId: opts.eventId,
attendanceId: opts.attendanceId,
participantId: opts.participantId,
checkedInAt,
deviceId,
});
await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt);
const nextCount = participant.checkInCount + 1;
return {
ok: true,
pendingSync: true,
result: {
attendanceId: opts.attendanceId,
attendanceName: opts.attendanceName || session.name,
participantId: opts.participantId,
displayName: participant.displayName,
email: participant.email || '',
phone: participant.phone || '',
checkInCount: nextCount,
lastCheckedInAt: checkedInAt,
allowDuplicateCheckIn: allowDuplicate,
alreadyCheckedIn: true,
},
};
}
/**
* Check-in: live API when online with empty outbox; otherwise local outbox.
*/
export async function performAppCheckIn(opts: {
eventId: string;
attendanceId: string;
attendanceName: string;
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)) {
return queueLocalCheckIn(opts);
}
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.',
};
}
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 };
}

88
src/offline/db.ts Normal file
View File

@ -0,0 +1,88 @@
import { open, type DB } from '@op-engineering/op-sqlite';
let db: DB | null = null;
let readyPromise: Promise<DB> | null = null;
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS offline_events (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
start_date TEXT,
end_date TEXT,
location TEXT,
enabled_at TEXT NOT NULL,
last_synced_at TEXT,
snapshot_version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS offline_sessions (
id TEXT PRIMARY KEY NOT NULL,
event_id TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL,
allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_offline_sessions_event ON offline_sessions(event_id);
CREATE TABLE IF NOT EXISTS offline_participants (
event_id TEXT NOT NULL,
attendance_id TEXT NOT NULL,
participant_id TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
email TEXT,
phone TEXT,
display_name TEXT NOT NULL,
already_checked_in INTEGER NOT NULL DEFAULT 0,
check_in_count INTEGER NOT NULL DEFAULT 0,
last_checked_in_at TEXT,
PRIMARY KEY (attendance_id, participant_id),
FOREIGN KEY (attendance_id) REFERENCES offline_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_offline_participants_event ON offline_participants(event_id);
CREATE INDEX IF NOT EXISTS idx_offline_participants_name ON offline_participants(attendance_id, display_name);
CREATE TABLE IF NOT EXISTS outbox (
client_op_id TEXT PRIMARY KEY NOT NULL,
event_id TEXT NOT NULL,
attendance_id TEXT NOT NULL,
participant_id TEXT NOT NULL,
checked_in_at TEXT NOT NULL,
device_id TEXT NOT NULL,
status TEXT NOT NULL,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(status);
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);
`;
export async function getDb(): Promise<DB> {
if (db) return db;
if (!readyPromise) {
readyPromise = (async () => {
const instance = open({ name: 'eventbuzz_checkins_offline.db' });
await instance.execute('PRAGMA foreign_keys = ON;');
await instance.executeBatch(
SCHEMA_SQL.split(';')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => [`${s};`] as [string]),
);
db = instance;
return instance;
})();
}
return readyPromise;
}

19
src/offline/deviceId.ts Normal file
View File

@ -0,0 +1,19 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { v4 as uuidv4 } from 'uuid';
const KEY = 'eventbuzz_checkins_device_id';
let cached: string | null = null;
export async function getDeviceId(): Promise<string> {
if (cached) return cached;
const existing = await AsyncStorage.getItem(KEY);
if (existing) {
cached = existing;
return existing;
}
const id = uuidv4();
await AsyncStorage.setItem(KEY, id);
cached = id;
return id;
}

81
src/offline/download.ts Normal file
View File

@ -0,0 +1,81 @@
import { attendancesApi } from '../api/attendances';
import type { CheckinsEvent } from '../api/types';
import {
replaceParticipantsForSession,
replaceSessionsForEvent,
upsertOfflineEvent,
} from './repos';
export type DownloadProgress = {
phase: 'sessions' | 'participants' | 'done';
sessionIndex: number;
sessionCount: number;
sessionName?: string;
};
export async function downloadOfflinePack(
event: Pick<CheckinsEvent, '_id' | 'name' | 'startDate' | 'endDate' | 'location'>,
onProgress?: (p: DownloadProgress) => void,
): Promise<{ ok: true; sessionCount: number; participantCount: number } | { ok: false; error: string }> {
const sessionsRes = await attendancesApi.list(event._id);
if (sessionsRes.error || !sessionsRes.data) {
return { ok: false, error: sessionsRes.error || 'Failed to load attendance sessions' };
}
const active = (sessionsRes.data.attendances ?? []).filter((s) => s.status === 'active');
onProgress?.({ phase: 'sessions', sessionIndex: 0, sessionCount: active.length });
await upsertOfflineEvent({
_id: event._id,
name: event.name,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
attendanceSessionCount: active.length,
});
await replaceSessionsForEvent(event._id, active);
let participantCount = 0;
for (let i = 0; i < active.length; i++) {
const session = active[i];
onProgress?.({
phase: 'participants',
sessionIndex: i + 1,
sessionCount: active.length,
sessionName: session.name,
});
const partRes = await attendancesApi.searchParticipants(event._id, session._id);
if (partRes.error || !partRes.data) {
return {
ok: false,
error: partRes.error || `Failed to download participants for ${session.name}`,
};
}
const participants = partRes.data.participants ?? [];
participantCount += participants.length;
await replaceParticipantsForSession(event._id, session._id, participants);
}
onProgress?.({ phase: 'done', sessionIndex: active.length, sessionCount: active.length });
return { ok: true, sessionCount: active.length, participantCount };
}
/** Re-download snapshot without clearing pending outbox rows. */
export async function refreshOfflinePack(
event: Pick<CheckinsEvent, '_id' | 'name' | 'startDate' | 'endDate' | 'location'>,
onProgress?: (p: DownloadProgress) => void,
): Promise<{ ok: true; sessionCount: number; participantCount: number } | { ok: false; error: string }> {
return downloadOfflinePack(event, onProgress);
}
export async function refreshSessionRoster(
eventId: string,
attendanceId: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const partRes = await attendancesApi.searchParticipants(eventId, attendanceId);
if (partRes.error || !partRes.data) {
return { ok: false, error: partRes.error || 'Failed to refresh roster' };
}
await replaceParticipantsForSession(eventId, attendanceId, partRes.data.participants ?? []);
return { ok: true };
}

416
src/offline/repos.ts Normal file
View File

@ -0,0 +1,416 @@
import type { AttendanceRecord, AttendanceSearchParticipant, CheckinsEvent } from '../api/types';
import { getDb } from './db';
import type {
EventOfflineStatus,
OfflineEventRow,
OfflineParticipantRow,
OfflineSessionRow,
OutboxRow,
OutboxStatus,
} from './types';
function nowIso() {
return new Date().toISOString();
}
export async function upsertOfflineEvent(event: CheckinsEvent): Promise<void> {
const db = await getDb();
const enabledAt = nowIso();
await db.execute(
`INSERT INTO offline_events (id, name, start_date, end_date, location, enabled_at, last_synced_at, snapshot_version)
VALUES (?, ?, ?, ?, ?, ?, NULL, 1)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
start_date = excluded.start_date,
end_date = excluded.end_date,
location = excluded.location,
snapshot_version = offline_events.snapshot_version + 1`,
[
event._id,
event.name,
event.startDate ?? null,
event.endDate ?? null,
event.location ?? null,
enabledAt,
],
);
}
export async function getOfflineEvent(eventId: string): Promise<OfflineEventRow | null> {
const db = await getDb();
const res = await db.execute(`SELECT * FROM offline_events WHERE id = ?`, [eventId]);
const row = res.rows[0];
if (!row) return null;
return {
id: String(row.id),
name: String(row.name),
startDate: row.start_date != null ? String(row.start_date) : null,
endDate: row.end_date != null ? String(row.end_date) : null,
location: row.location != null ? String(row.location) : null,
enabledAt: String(row.enabled_at),
lastSyncedAt: row.last_synced_at != null ? String(row.last_synced_at) : null,
snapshotVersion: Number(row.snapshot_version) || 1,
};
}
export async function listOfflineEventIds(): Promise<string[]> {
const db = await getDb();
const res = await db.execute(`SELECT id FROM offline_events`);
return res.rows.map((r) => String(r.id));
}
export async function listOfflineEvents(): Promise<OfflineEventRow[]> {
const db = await getDb();
const res = await db.execute(`SELECT * FROM offline_events ORDER BY name COLLATE NOCASE ASC`);
return res.rows.map((row) => ({
id: String(row.id),
name: String(row.name),
startDate: row.start_date != null ? String(row.start_date) : null,
endDate: row.end_date != null ? String(row.end_date) : null,
location: row.location != null ? String(row.location) : null,
enabledAt: String(row.enabled_at),
lastSyncedAt: row.last_synced_at != null ? String(row.last_synced_at) : null,
snapshotVersion: Number(row.snapshot_version) || 1,
}));
}
export async function setEventLastSyncedAt(eventId: string, iso: string): Promise<void> {
const db = await getDb();
await db.execute(`UPDATE offline_events SET last_synced_at = ? WHERE id = ?`, [iso, eventId]);
}
export async function replaceSessionsForEvent(
eventId: string,
sessions: AttendanceRecord[],
): Promise<void> {
const db = await getDb();
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 (?, ?, ?, ?, ?)`,
[
s._id,
eventId,
s.name,
s.status,
s.settings?.allowDuplicateCheckIn ? 1 : 0,
],
);
}
}
export async function listOfflineSessions(eventId: string): Promise<OfflineSessionRow[]> {
const db = await getDb();
const res = await db.execute(
`SELECT * FROM offline_sessions WHERE event_id = ? AND status = 'active' ORDER BY name ASC`,
[eventId],
);
return res.rows.map((row) => ({
id: String(row.id),
eventId: String(row.event_id),
name: String(row.name),
status: String(row.status),
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
}));
}
export async function getOfflineSession(attendanceId: string): Promise<OfflineSessionRow | null> {
const db = await getDb();
const res = await db.execute(`SELECT * FROM offline_sessions WHERE id = ?`, [attendanceId]);
const row = res.rows[0];
if (!row) return null;
return {
id: String(row.id),
eventId: String(row.event_id),
name: String(row.name),
status: String(row.status),
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
};
}
export async function replaceParticipantsForSession(
eventId: string,
attendanceId: string,
participants: AttendanceSearchParticipant[],
): Promise<void> {
const db = await getDb();
await db.execute(`DELETE FROM offline_participants WHERE attendance_id = ?`, [attendanceId]);
const batch: [string, (string | number | null)[]][] = participants.map((p) => [
`INSERT INTO offline_participants (
event_id, attendance_id, participant_id, first_name, last_name, email, phone,
display_name, already_checked_in, check_in_count, last_checked_in_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
eventId,
attendanceId,
p._id,
p.firstName ?? null,
p.lastName ?? null,
p.email ?? null,
p.phone ?? null,
p.displayName,
p.alreadyCheckedIn ? 1 : 0,
p.checkInCount ?? 0,
p.lastCheckedInAt ?? null,
],
]);
if (batch.length) {
// Chunk to avoid huge single batches
const CHUNK = 200;
for (let i = 0; i < batch.length; i += CHUNK) {
await db.executeBatch(batch.slice(i, i + CHUNK) as [string, (string | number | null)[]][]);
}
}
}
function mapParticipant(row: Record<string, unknown>): OfflineParticipantRow {
return {
eventId: String(row.event_id),
attendanceId: String(row.attendance_id),
participantId: String(row.participant_id),
firstName: row.first_name != null ? String(row.first_name) : null,
lastName: row.last_name != null ? String(row.last_name) : null,
email: row.email != null ? String(row.email) : null,
phone: row.phone != null ? String(row.phone) : null,
displayName: String(row.display_name),
alreadyCheckedIn: Number(row.already_checked_in) || 0,
checkInCount: Number(row.check_in_count) || 0,
lastCheckedInAt: row.last_checked_in_at != null ? String(row.last_checked_in_at) : null,
};
}
export async function listOfflineParticipants(
attendanceId: string,
): Promise<AttendanceSearchParticipant[]> {
const db = await getDb();
const res = await db.execute(
`SELECT * FROM offline_participants WHERE attendance_id = ? ORDER BY display_name COLLATE NOCASE ASC`,
[attendanceId],
);
return res.rows.map((row) => {
const p = mapParticipant(row as Record<string, unknown>);
return {
_id: p.participantId,
firstName: p.firstName ?? undefined,
lastName: p.lastName ?? undefined,
email: p.email ?? undefined,
phone: p.phone ?? undefined,
displayName: p.displayName,
alreadyCheckedIn: p.alreadyCheckedIn === 1,
checkInCount: p.checkInCount,
lastCheckedInAt: p.lastCheckedInAt,
};
});
}
export async function getOfflineParticipant(
attendanceId: string,
participantId: string,
): Promise<OfflineParticipantRow | null> {
const db = await getDb();
const res = await db.execute(
`SELECT * FROM offline_participants WHERE attendance_id = ? AND participant_id = ?`,
[attendanceId, participantId],
);
const row = res.rows[0];
if (!row) return null;
return mapParticipant(row as Record<string, unknown>);
}
export async function applyLocalCheckIn(
attendanceId: string,
participantId: string,
checkedInAt: string,
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE offline_participants
SET already_checked_in = 1,
check_in_count = check_in_count + 1,
last_checked_in_at = ?
WHERE attendance_id = ? AND participant_id = ?`,
[checkedInAt, attendanceId, participantId],
);
}
export async function updateParticipantFromSync(
attendanceId: string,
participantId: string,
checkInCount: number,
lastCheckedInAt: string | null,
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE offline_participants
SET already_checked_in = ?,
check_in_count = ?,
last_checked_in_at = ?
WHERE attendance_id = ? AND participant_id = ?`,
[checkInCount > 0 ? 1 : 0, checkInCount, lastCheckedInAt, attendanceId, participantId],
);
}
export async function insertOutbox(row: {
clientOpId: string;
eventId: string;
attendanceId: string;
participantId: string;
checkedInAt: string;
deviceId: string;
}): 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, ?, ?)`,
[
row.clientOpId,
row.eventId,
row.attendanceId,
row.participantId,
row.checkedInAt,
row.deviceId,
ts,
ts,
],
);
}
function mapOutbox(row: Record<string, unknown>): OutboxRow {
return {
clientOpId: String(row.client_op_id),
eventId: String(row.event_id),
attendanceId: String(row.attendance_id),
participantId: String(row.participant_id),
checkedInAt: String(row.checked_in_at),
deviceId: String(row.device_id),
status: String(row.status) as OutboxStatus,
lastError: row.last_error != null ? String(row.last_error) : null,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
};
}
export async function listPendingOutbox(eventId?: string): Promise<OutboxRow[]> {
const db = await getDb();
const res = eventId
? await db.execute(
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
[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>));
}
export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> {
const db = await getDb();
const res = await db.execute(
`SELECT * FROM outbox WHERE event_id = ? AND status = 'conflict' ORDER BY updated_at DESC`,
[eventId],
);
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
}
export async function countOutboxByStatus(
eventId: string,
statuses: OutboxStatus[],
): Promise<number> {
const db = await getDb();
const placeholders = statuses.map(() => '?').join(',');
const res = await db.execute(
`SELECT COUNT(*) as c FROM outbox WHERE event_id = ? AND status IN (${placeholders})`,
[eventId, ...statuses],
);
return Number(res.rows[0]?.c) || 0;
}
export async function countPendingForSession(attendanceId: string): Promise<number> {
const db = await getDb();
const res = await db.execute(
`SELECT COUNT(*) as c FROM outbox WHERE attendance_id = ? AND status IN ('pending', 'syncing')`,
[attendanceId],
);
return Number(res.rows[0]?.c) || 0;
}
export async function markOutboxStatus(
clientOpId: string,
status: OutboxStatus,
lastError?: string | null,
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE outbox SET status = ?, last_error = ?, updated_at = ? WHERE client_op_id = ?`,
[status, lastError ?? null, nowIso(), clientOpId],
);
}
export async function markOutboxSyncing(clientOpIds: string[]): Promise<void> {
if (!clientOpIds.length) return;
const db = await getDb();
const placeholders = clientOpIds.map(() => '?').join(',');
await db.execute(
`UPDATE outbox SET status = 'syncing', updated_at = ? WHERE client_op_id IN (${placeholders})`,
[nowIso(), ...clientOpIds],
);
}
export async function dismissConflict(clientOpId: string): Promise<void> {
// Keep as conflict but mark acknowledged by appending note; UI filters dismissed via status synced-from-conflict
await markOutboxStatus(clientOpId, 'synced', 'Dismissed by staff');
}
export async function getEventOfflineStatus(eventId: string): Promise<EventOfflineStatus> {
const event = await getOfflineEvent(eventId);
if (!event) {
return {
enabled: false,
pendingCount: 0,
conflictCount: 0,
lastSyncedAt: null,
enabledAt: null,
};
}
const [pendingCount, conflictCount] = await Promise.all([
countOutboxByStatus(eventId, ['pending', 'syncing']),
countOutboxByStatus(eventId, ['conflict']),
]);
return {
enabled: true,
pendingCount,
conflictCount,
lastSyncedAt: event.lastSyncedAt,
enabledAt: event.enabledAt,
};
}
export async function removeOfflineEventData(
eventId: string,
{ force = false }: { force?: boolean } = {},
): Promise<{ ok: true } | { ok: false; error: string }> {
const pending = await countOutboxByStatus(eventId, ['pending', 'syncing']);
if (pending > 0 && !force) {
return {
ok: false,
error: `${pending} unsynced check-in${pending === 1 ? '' : 's'} remaining. Sync or confirm force remove.`,
};
}
const db = await getDb();
await db.execute(`DELETE FROM outbox WHERE event_id = ?`, [eventId]);
await db.execute(`DELETE FROM offline_participants WHERE event_id = ?`, [eventId]);
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
await db.execute(`DELETE FROM offline_events WHERE id = ?`, [eventId]);
return { ok: true };
}
export async function isOfflinePackActive(eventId: string): Promise<boolean> {
const event = await getOfflineEvent(eventId);
return Boolean(event);
}

174
src/offline/syncEngine.ts Normal file
View File

@ -0,0 +1,174 @@
import NetInfo from '@react-native-community/netinfo';
import { attendancesApi } from '../api/attendances';
import { getDeviceId } from './deviceId';
import { refreshSessionRoster } from './download';
import {
listPendingOutbox,
markOutboxStatus,
markOutboxSyncing,
setEventLastSyncedAt,
updateParticipantFromSync,
} from './repos';
import type { SyncOpResult } from './types';
type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void;
let syncing = false;
const listeners = new Set<SyncListener>();
function emit(lastError: string | null = null) {
const snapshot = { syncing, lastError };
listeners.forEach((l) => l(snapshot));
}
export function subscribeSyncEngine(listener: SyncListener): () => void {
listeners.add(listener);
listener({ syncing, lastError: null });
return () => listeners.delete(listener);
}
export function isSyncEngineRunning(): boolean {
return syncing;
}
async function flushGroup(
eventId: string,
attendanceId: string,
ops: { clientOpId: string; participantId: string; checkedInAt: string }[],
deviceId: string,
): Promise<string | null> {
await markOutboxSyncing(ops.map((o) => o.clientOpId));
const res = await attendancesApi.syncCheckIns(eventId, attendanceId, {
deviceId,
operations: ops,
});
if (res.error || !res.data) {
const err =
res.status === 404
? 'Sync API not found on this server. Deploy the backend offline-sync route, or point API_BASE_URL at a backend that includes POST .../check-ins/sync.'
: res.error || 'Sync failed';
for (const op of ops) {
await markOutboxStatus(op.clientOpId, 'pending', err);
}
return err;
}
const results: SyncOpResult[] = res.data.results || [];
const byId = new Map(results.map((r) => [r.clientOpId, r]));
for (const op of ops) {
const r = byId.get(op.clientOpId);
if (!r) {
await markOutboxStatus(op.clientOpId, 'pending', 'Missing result from server');
continue;
}
if (r.status === 'applied' || r.status === 'duplicate') {
await markOutboxStatus(op.clientOpId, 'synced', r.message || null);
if (typeof r.checkInCount === 'number') {
await updateParticipantFromSync(
attendanceId,
op.participantId,
r.checkInCount,
r.lastCheckedInAt ?? null,
);
}
} else if (r.status === 'conflict') {
await markOutboxStatus(op.clientOpId, 'conflict', r.message || 'Conflict');
if (typeof r.checkInCount === 'number') {
await updateParticipantFromSync(
attendanceId,
op.participantId,
r.checkInCount,
r.lastCheckedInAt ?? null,
);
}
} else {
await markOutboxStatus(op.clientOpId, 'rejected', r.message || 'Rejected');
}
}
// Light roster refresh to pick up other devices
await refreshSessionRoster(eventId, 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' };
}
const net = await NetInfo.fetch();
if (!net.isConnected || net.isInternetReachable === false) {
return { ok: false, flushed: 0, 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 deviceId = await getDeviceId();
const groups = new Map<string, typeof pending>();
for (const row of pending) {
const key = `${row.eventId}::${row.attendanceId}`;
const list = groups.get(key) || [];
list.push(row);
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(
eid,
aid,
chunk.map((r) => ({
clientOpId: r.clientOpId,
participantId: r.participantId,
checkedInAt: r.checkedInAt,
})),
deviceId,
);
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);
}
}

65
src/offline/types.ts Normal file
View File

@ -0,0 +1,65 @@
export type OutboxStatus = 'pending' | 'syncing' | 'synced' | 'conflict' | 'rejected';
export type OfflineEventRow = {
id: string;
name: string;
startDate: string | null;
endDate: string | null;
location: string | null;
enabledAt: string;
lastSyncedAt: string | null;
snapshotVersion: number;
};
export type OfflineSessionRow = {
id: string;
eventId: string;
name: string;
status: string;
allowDuplicateCheckIn: number;
};
export type OfflineParticipantRow = {
eventId: string;
attendanceId: string;
participantId: string;
firstName: string | null;
lastName: string | null;
email: string | null;
phone: string | null;
displayName: string;
alreadyCheckedIn: number;
checkInCount: number;
lastCheckedInAt: string | null;
};
export type OutboxRow = {
clientOpId: string;
eventId: string;
attendanceId: string;
participantId: string;
checkedInAt: string;
deviceId: string;
status: OutboxStatus;
lastError: string | null;
createdAt: string;
updatedAt: string;
};
export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected';
export type SyncOpResult = {
clientOpId: string;
status: SyncOpResultStatus;
checkInCount?: number;
lastCheckedInAt?: string | null;
message?: string;
};
export type EventOfflineStatus = {
enabled: boolean;
pendingCount: number;
conflictCount: number;
lastSyncedAt: string | null;
enabledAt: string | null;
};

View File

@ -1,125 +1,448 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Platform,
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { attendancesApi } from '../api/attendances'; import { attendancesApi } from '../api/attendances';
import type { AttendanceRecord } from '../api/types'; import type { AttendanceRecord } from '../api/types';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { colors, radii, spacing, typography } from '../theme'; import { useOffline } from '../offline/OfflineContext';
import { Badge, Card, GradientHeader } from '../components/ui'; import {
dismissConflict,
getOfflineEvent,
listConflictOutbox,
listOfflineParticipants,
listOfflineSessions,
} from '../offline/repos';
import { colors, radii, shadows, spacing, typography } from '../theme';
import { AppIcon, type AppIconName, Card } from '../components/ui';
type Props = NativeStackScreenProps<RootStackParamList, 'AttendanceSessions'>; type Props = NativeStackScreenProps<RootStackParamList, 'AttendanceSessions'>;
const SESSION_ICONS: AppIconName[] = ['business-outline', 'people-outline'];
const CHECKIN_GREEN = '#22C55E';
const PEOPLE_BLUE = '#60A5FA';
function SessionsHeader({
eventName,
onBack,
onSync,
syncing,
pendingCount,
isOnline,
offlineReady,
}: {
eventName: string;
onBack: () => void;
onSync: () => void;
syncing: boolean;
pendingCount: number;
isOnline: boolean;
offlineReady: boolean;
}) {
const insets = useSafeAreaInsets();
return (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<View style={styles.headerBg} />
<View style={styles.headerCircle} />
<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}>Events</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>
<Text style={styles.headerTitle} numberOfLines={2}>
{eventName}
</Text>
<Text style={styles.headerSubtitle}>
{offlineReady
? isOnline
? 'Offline pack ready · Select a session'
: 'Working offline · Select a session'
: 'Select an attendance session'}
</Text>
</View>
);
}
function SessionsListHeader({ count }: { count: number }) {
return (
<Text style={styles.listHeader}>
{count} session{count === 1 ? '' : 's'} available
</Text>
);
}
export function AttendanceSessionsScreen({ navigation, route }: Props) { export function AttendanceSessionsScreen({ navigation, route }: Props) {
const { eventId, eventName } = route.params; const { eventId, eventName } = route.params;
const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
const [sessions, setSessions] = useState<AttendanceRecord[]>([]); const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fromCache, setFromCache] = useState(false);
const status = statusByEvent[eventId];
const offlineReady = Boolean(status?.enabled);
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
const res = await attendancesApi.list(eventId);
if (res.error) { if (isOnline) {
setError(res.error); const res = await attendancesApi.list(eventId);
setSessions([]); if (!res.error && res.data) {
} else { const active = (res.data.attendances ?? []).filter((s) => s.status === 'active');
const active = (res.data?.attendances ?? []).filter((s) => s.status === 'active'); setSessions(active);
setSessions(active); setFromCache(false);
return;
}
// fall through to cache on failure
if (!(await getOfflineEvent(eventId))) {
setError(res.error || 'Failed to load sessions');
setSessions([]);
setFromCache(false);
return;
}
} }
}, [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(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);
}, [eventId, isOnline]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setLoading(true); setLoading(true);
await load(); await load();
await refreshStatuses();
setLoading(false); setLoading(false);
})(); })();
}, [load]); }, [load, refreshStatuses]);
const handleSync = async () => {
const result = await syncNow(eventId);
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.');
}
const conflicts = await listConflictOutbox(eventId);
if (conflicts.length) {
Alert.alert(
'Conflicts',
`${conflicts.length} check-in${conflicts.length === 1 ? '' : 's'} conflicted (already checked in elsewhere). Dismiss them?`,
[
{ text: 'Later', style: 'cancel' },
{
text: 'Dismiss all',
onPress: async () => {
for (const c of conflicts) {
await dismissConflict(c.clientOpId);
}
await refreshStatuses();
},
},
],
);
}
};
const renderItem = ({ item, index }: { item: AttendanceRecord; index: number }) => {
const checkIns = item.totalCheckedIn ?? 0;
const people = item.uniqueParticipants ?? 0;
const iconName = SESSION_ICONS[index % SESSION_ICONS.length];
return (
<Pressable
style={styles.cardPress}
onPress={() =>
navigation.navigate('Participants', {
eventId,
eventName,
attendanceId: item._id,
attendanceName: item.name,
allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
})
}
>
<Card style={styles.card} padded>
<View style={styles.cardRow}>
<View style={styles.iconBox}>
<AppIcon name={iconName} size={22} color={colors.primary} />
</View>
<View style={styles.cardText}>
<Text style={styles.cardTitle}>{item.name}</Text>
<View style={styles.statsRow}>
<View style={styles.stat}>
<AppIcon name="checkmark-circle-outline" size={14} color={CHECKIN_GREEN} />
<Text style={styles.statText}>
{checkIns} check-in{checkIns === 1 ? '' : 's'}
</Text>
</View>
<Text style={styles.statDot}>·</Text>
<View style={styles.stat}>
<AppIcon name="people-outline" size={14} color={PEOPLE_BLUE} />
<Text style={styles.statText}>
{people} {people === 1 ? 'person' : 'people'}
</Text>
</View>
</View>
</View>
<View style={styles.chev}>
<AppIcon name="chevron-forward-outline" size={16} color={colors.primary} />
</View>
</View>
</Card>
</Pressable>
);
};
return ( return (
<View style={styles.wrap}> <View style={styles.wrap}>
<GradientHeader <SessionsHeader
title={eventName} eventName={eventName}
subtitle="Select an attendance session" onBack={() => navigation.goBack()}
style={styles.header} onSync={() => { handleSync(); }}
syncing={syncing}
pendingCount={status?.pendingCount ?? 0}
isOnline={isOnline}
offlineReady={offlineReady}
/> />
{fromCache ? (
<View style={styles.cacheBanner}>
<AppIcon name="cloud-offline-outline" size={14} color="#92400E" />
<Text style={styles.cacheBannerText}>Showing offline data</Text>
</View>
) : null}
{loading ? ( {loading ? (
<ActivityIndicator style={styles.center} color={colors.primary} /> <ActivityIndicator style={styles.center} color={colors.primary} />
) : ( ) : (
<FlatList <FlatList
data={sessions} data={sessions}
keyExtractor={(item) => item._id} keyExtractor={(item) => item._id}
renderItem={renderItem}
contentInsetAdjustmentBehavior="never"
ListHeaderComponent={<SessionsListHeader count={sessions.length} />}
contentContainerStyle={sessions.length === 0 ? styles.emptyList : styles.list} contentContainerStyle={sessions.length === 0 ? styles.emptyList : styles.list}
ListEmptyComponent={ ListEmptyComponent={
<Text style={styles.empty}>{error || 'No active attendance sessions.'}</Text> <Text style={styles.empty}>{error || 'No active attendance sessions.'}</Text>
} }
renderItem={({ item }) => (
<Pressable
style={styles.cardPress}
onPress={() =>
navigation.navigate('Participants', {
eventId,
eventName,
attendanceId: item._id,
attendanceName: item.name,
allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
})
}
>
<Card style={styles.card}>
<View style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.cardTitle}>{item.name}</Text>
<Text style={styles.cardMeta}>
{item.totalCheckedIn ?? 0} check-in
{(item.totalCheckedIn ?? 0) === 1 ? '' : 's'} ·{' '}
{item.uniqueParticipants ?? 0} people
</Text>
<View style={styles.badges}>
<Badge>{item.uniqueParticipants ?? 0} attendees</Badge>
</View>
</View>
<View style={styles.chev}>
<Text style={styles.chevText}>›</Text>
</View>
</View>
</Card>
</Pressable>
)}
/> />
)} )}
</View> </View>
); );
} }
const cardShadow = Platform.OS === 'ios' ? shadows.card.ios : shadows.card.android;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background }, wrap: { flex: 1, backgroundColor: colors.background },
header: { paddingBottom: 26 }, header: {
list: { padding: spacing.lg, paddingTop: spacing.lg }, paddingHorizontal: spacing.xl,
emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 }, paddingBottom: spacing.lg,
overflow: 'hidden',
},
headerBg: {
...StyleSheet.absoluteFill,
backgroundColor: colors.primary,
zIndex: 0,
},
headerCircle: {
position: 'absolute',
width: 128,
height: 128,
borderRadius: 64,
right: -30,
top: -30,
backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0,
},
headerTopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.lg,
zIndex: 1,
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
alignSelf: 'flex-start',
},
backLabel: {
color: 'rgba(255,255,255,0.85)',
fontSize: 14,
fontWeight: '500',
},
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',
},
headerTitle: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
zIndex: 1,
},
headerSubtitle: {
color: 'rgba(255,255,255,0.90)',
fontSize: 14,
zIndex: 1,
},
cacheBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(217,119,6,0.12)',
paddingHorizontal: spacing.lg,
paddingVertical: 10,
},
cacheBannerText: {
color: '#92400E',
fontSize: 12,
fontWeight: '600',
},
listHeader: {
...typography.small,
color: colors.muted,
marginBottom: spacing.md,
},
list: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.xl,
},
emptyList: {
flexGrow: 1,
justifyContent: 'center',
padding: spacing.xl,
paddingTop: spacing.lg,
},
center: { marginTop: 32 }, center: { marginTop: 32 },
cardPress: { marginBottom: 12 }, cardPress: { marginBottom: 12 },
card: { borderRadius: radii.xl, borderWidth: 0 }, card: {
row: { flexDirection: 'row', alignItems: 'center', gap: 12 }, borderRadius: radii.xl,
rowText: { flex: 1 }, borderWidth: 0,
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '800', marginBottom: 6 }, ...cardShadow,
cardMeta: { ...typography.small, color: colors.muted }, },
badges: { marginTop: 10 }, cardRow: {
chev: { flexDirection: 'row',
width: 34, alignItems: 'center',
height: 34, gap: 14,
borderRadius: 17, },
iconBox: {
width: 48,
height: 48,
borderRadius: 14,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(247,148,29,0.12)', backgroundColor: 'rgba(247,148,29,0.14)',
},
cardText: { flex: 1, minWidth: 0 },
cardTitle: {
color: colors.text,
fontSize: 15,
fontWeight: '700',
marginBottom: 6,
},
statsRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
},
stat: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statText: {
color: colors.muted,
fontSize: 12,
},
statDot: {
color: '#D1D5DB',
fontSize: 12,
},
chev: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(247,148,29,0.10)',
}, },
chevText: { color: colors.primaryDark, fontSize: 22, lineHeight: 22, fontWeight: '700' },
empty: { textAlign: 'center', color: colors.muted, fontSize: 15 }, empty: { textAlign: 'center', color: colors.muted, fontSize: 15 },
}); });

View File

@ -1,20 +1,27 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Image,
Platform,
Pressable, Pressable,
RefreshControl, RefreshControl,
StyleSheet, StyleSheet,
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { checkinsApi } from '../api/checkins'; import { checkinsApi } from '../api/checkins';
import type { CheckinsEvent } from '../api/types'; import type { CheckinsEvent } from '../api/types';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { downloadOfflinePack, refreshOfflinePack } from '../offline/download';
import { useOffline } from '../offline/OfflineContext';
import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, shadows, spacing, typography } from '../theme';
import { AvatarInitials, Badge, Card, GradientHeader } from '../components/ui'; import { AppIcon, AvatarInitials, Card } from '../components/ui';
type Props = NativeStackScreenProps<RootStackParamList, 'Events'>; type Props = NativeStackScreenProps<RootStackParamList, 'Events'>;
@ -25,23 +32,141 @@ function formatDate(iso?: string) {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} }
function formatDateRange(start?: string, end?: string) {
const startLabel = formatDate(start);
const endLabel = formatDate(end);
if (!startLabel) return '';
if (!endLabel || endLabel === startLabel) return startLabel;
return `${startLabel} – ${endLabel}`;
}
function EventsHeader({
userEmail,
onLogout,
onSync,
syncing,
pendingTotal,
isOnline,
}: {
userEmail?: string | null;
onLogout: () => void;
onSync: () => void;
syncing: boolean;
pendingTotal: number;
isOnline: boolean;
}) {
const insets = useSafeAreaInsets();
return (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<View style={styles.headerBg} />
<View style={styles.headerCircleA} />
<View style={styles.headerCircleB} />
<View style={styles.headerTopRow}>
<Image
source={require('../assets/eventbuzz-logo-white.png')}
style={styles.logo}
resizeMode="contain"
/>
<View style={styles.headerActions}>
<Pressable
onPress={onSync}
hitSlop={10}
style={styles.syncBtn}
disabled={syncing || !isOnline}
>
{syncing ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<AppIcon name="cloud-upload-outline" size={18} color="rgba(255,255,255,0.9)" />
)}
<Text style={styles.syncText}>
Sync{pendingTotal > 0 ? ` (${pendingTotal})` : ''}
</Text>
</Pressable>
<Pressable onPress={onLogout} hitSlop={10} style={styles.logoutBtn}>
<AppIcon name="log-out-outline" size={20} color="rgba(255,255,255,0.85)" />
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
</View>
</View>
<View style={styles.userRow}>
<AvatarInitials email={userEmail} size={40} />
<View style={styles.userText}>
<Text style={styles.signedInLabel}>Signed in as</Text>
<Text style={styles.signedInEmail} numberOfLines={1}>
{userEmail || '—'}
</Text>
</View>
<View style={[styles.onlinePill, !isOnline && styles.offlinePill]}>
<Text style={styles.onlinePillText}>{isOnline ? 'Online' : 'Offline'}</Text>
</View>
</View>
</View>
);
}
function EventsListHeader({ count }: { count: number }) {
return (
<View style={styles.listHeader}>
<Text style={styles.listHeaderTitle}>Your Events</Text>
<Text style={styles.listHeaderCount}>
{count} event{count === 1 ? '' : 's'}
</Text>
</View>
);
}
export function EventsScreen({ navigation }: Props) { export function EventsScreen({ navigation }: Props) {
const { signOut, user } = useAuth(); const { signOut, user } = useAuth();
const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
const [events, setEvents] = useState<CheckinsEvent[]>([]); const [events, setEvents] = useState<CheckinsEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busyEventId, setBusyEventId] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
const res = await checkinsApi.listEvents(); if (isOnline) {
if (res.error) { const res = await checkinsApi.listEvents();
setError(res.error); if (!res.error && res.data) {
setEvents([]); setEvents(res.data.events ?? []);
} else { await refreshStatuses();
setEvents(res.data?.events ?? []); return;
}
} }
}, []);
// Offline / API failure: show locally downloaded events
const local = await listOfflineEvents();
if (!local.length) {
setEvents([]);
setError(
isOnline
? 'Failed to load events.'
: 'You are offline and have no downloaded events. Connect and Enable Offline first.',
);
await refreshStatuses();
return;
}
const mapped: CheckinsEvent[] = [];
for (const e of local) {
const sessions = await listOfflineSessions(e.id);
mapped.push({
_id: e.id,
name: e.name,
startDate: e.startDate ?? undefined,
endDate: e.endDate ?? undefined,
location: e.location ?? undefined,
attendanceSessionCount: sessions.length,
});
}
setEvents(mapped);
setError(null);
await refreshStatuses();
}, [refreshStatuses, isOnline]);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
@ -57,65 +182,214 @@ export function EventsScreen({ navigation }: Props) {
setRefreshing(false); setRefreshing(false);
}; };
const renderItem = ({ item }: { item: CheckinsEvent }) => ( const handleLogout = async () => {
<Pressable await signOut();
style={styles.cardPress} navigation.reset({
onPress={() => index: 0,
navigation.navigate('AttendanceSessions', { routes: [{ name: 'Login' }],
eventId: item._id, });
eventName: item.name, };
})
} const handleSync = async () => {
> const result = await syncNow();
<Card style={styles.card} padded> if (!result.ok && result.error) {
<View style={styles.cardRow}> Alert.alert('Sync', result.error);
<View style={styles.cardText}> } else if (result.flushed > 0) {
<Text style={styles.cardTitle}>{item.name}</Text> Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
<View style={styles.metaRow}> } else {
<Text style={styles.cardMeta}> Alert.alert('Sync', 'Nothing pending to sync.');
{formatDate(item.startDate)} }
{item.endDate && item.endDate !== item.startDate };
? ` – ${formatDate(item.endDate)}`
: ''} const enableOffline = async (item: CheckinsEvent) => {
</Text> if (!isOnline) {
Alert.alert('Offline', 'Connect to the internet to download offline data.');
return;
}
setBusyEventId(item._id);
const result = await downloadOfflinePack(item, undefined);
setBusyEventId(null);
await refreshStatuses();
if (!result.ok) {
Alert.alert('Enable Offline', result.error);
return;
}
Alert.alert(
'Offline ready',
`Downloaded ${result.sessionCount} session${result.sessionCount === 1 ? '' : 's'} and ${result.participantCount} participants.`,
);
};
const refreshOffline = async (item: CheckinsEvent) => {
if (!isOnline) {
Alert.alert('Offline', 'Connect to the internet to refresh offline data.');
return;
}
setBusyEventId(item._id);
const result = await refreshOfflinePack(item);
setBusyEventId(null);
await refreshStatuses();
if (!result.ok) {
Alert.alert('Refresh Offline', result.error);
return;
}
Alert.alert('Refreshed', 'Offline snapshot updated. Pending check-ins were kept.');
};
const removeOffline = (item: CheckinsEvent) => {
Alert.alert(
'Remove Offline Data',
'Remove the downloaded roster for this event from this device?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
const result = await removeOfflineEventData(item._id);
if (!result.ok) {
Alert.alert('Cannot remove', result.error, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Force remove',
style: 'destructive',
onPress: async () => {
await removeOfflineEventData(item._id, { force: true });
await refreshStatuses();
},
},
]);
return;
}
await refreshStatuses();
},
},
],
);
};
const openOfflineMenu = (item: CheckinsEvent) => {
const status = statusByEvent[item._id];
if (status?.enabled) {
Alert.alert(item.name, 'Offline pack actions', [
{ text: 'Refresh Offline Data', onPress: () => { refreshOffline(item); } },
{ text: 'Remove Offline Data', style: 'destructive', onPress: () => removeOffline(item) },
{ text: 'Cancel', style: 'cancel' },
]);
} else {
Alert.alert(item.name, 'Download participants and sessions for offline check-in?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Enable Offline', onPress: () => { enableOffline(item); } },
]);
}
};
const pendingTotal = Object.values(statusByEvent).reduce((n, s) => n + s.pendingCount, 0);
const renderItem = ({ item }: { item: CheckinsEvent }) => {
const sessionCount = item.attendanceSessionCount;
const sessionLabel = `${sessionCount} attendance session${sessionCount === 1 ? '' : 's'}`;
const status = statusByEvent[item._id];
const busy = busyEventId === item._id;
return (
<Pressable
style={styles.cardPress}
onPress={() =>
navigation.navigate('AttendanceSessions', {
eventId: item._id,
eventName: item.name,
})
}
onLongPress={() => openOfflineMenu(item)}
>
<Card style={styles.card} padded>
<View style={styles.cardRow}>
<View style={styles.cardText}>
<Text style={styles.cardTitle}>{item.name}</Text>
<View style={styles.metaRow}>
<AppIcon name="calendar-outline" size={14} color={colors.primary} />
<Text style={styles.cardMeta}>{formatDateRange(item.startDate, item.endDate)}</Text>
</View>
{item.location ? (
<View style={styles.metaRow}>
<AppIcon name="location-outline" size={16} color={colors.primary} />
<Text style={[styles.cardMeta, styles.locationMeta]} numberOfLines={2}>
{item.location}
</Text>
</View>
) : null}
<View style={styles.badgeRow}>
<View style={styles.sessionBadge}>
<AppIcon name="people-outline" size={13} color={colors.primaryDark} />
<Text style={styles.sessionBadgeText}>{sessionLabel}</Text>
</View>
{status?.enabled ? (
<View
style={[
styles.offlineBadge,
status.pendingCount > 0 ? styles.offlineBadgePending : null,
]}
>
<AppIcon
name={status.pendingCount > 0 ? 'cloud-upload-outline' : 'cloud-done-outline'}
size={12}
color={status.pendingCount > 0 ? '#92400E' : '#166534'}
/>
<Text
style={[
styles.offlineBadgeText,
status.pendingCount > 0 ? styles.offlineBadgeTextPending : null,
]}
>
{status.pendingCount > 0
? `${status.pendingCount} pending`
: 'Offline ready'}
</Text>
</View>
) : null}
</View>
</View> </View>
{item.location ? <Text style={styles.cardMeta}>{item.location}</Text> : null}
<View style={styles.badges}> <View style={styles.cardSide}>
<Badge> <Pressable
{item.attendanceSessionCount} session hitSlop={8}
{item.attendanceSessionCount === 1 ? '' : 's'} style={styles.offlineAction}
</Badge> disabled={busy}
onPress={() => openOfflineMenu(item)}
>
{busy ? (
<ActivityIndicator size="small" color={colors.primaryDark} />
) : (
<AppIcon
name={status?.enabled ? 'cloud-done-outline' : 'cloud-download-outline'}
size={18}
color={colors.primaryDark}
/>
)}
</Pressable>
<View style={styles.chev}>
<AppIcon name="chevron-forward-outline" size={16} color={colors.primaryDark} />
</View>
</View> </View>
</View> </View>
<View style={styles.chev}> </Card>
<Text style={styles.chevText}>›</Text> </Pressable>
</View> );
</View> };
</Card>
</Pressable>
);
return ( return (
<View style={styles.wrap}> <View style={styles.wrap}>
<GradientHeader <EventsHeader
title="EventBuzz" userEmail={user?.email}
subtitle={user?.email ? `Signed in as ${user.email}` : 'Signed in'} onLogout={handleLogout}
right={ onSync={() => { handleSync(); }}
<Pressable onPress={signOut} hitSlop={10}> syncing={syncing}
<Text style={styles.logout}>Logout</Text> pendingTotal={pendingTotal}
</Pressable> isOnline={isOnline}
}
bottom={
<View style={styles.headerBottom}>
<AvatarInitials email={user?.email} />
<View style={styles.headerBottomText}>
<Text style={styles.headerBottomLabel}>Your Events</Text>
<Text style={styles.headerBottomCount}>
{events.length} event{events.length === 1 ? '' : 's'}
</Text>
</View>
</View>
}
/> />
{loading ? ( {loading ? (
@ -125,14 +399,12 @@ export function EventsScreen({ navigation }: Props) {
data={events} data={events}
keyExtractor={(item) => item._id} keyExtractor={(item) => item._id}
renderItem={renderItem} renderItem={renderItem}
contentInsetAdjustmentBehavior="never"
ListHeaderComponent={<EventsListHeader count={events.length} />}
contentContainerStyle={events.length === 0 ? styles.emptyList : styles.list} contentContainerStyle={events.length === 0 ? styles.emptyList : styles.list}
refreshControl={ refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListEmptyComponent={ ListEmptyComponent={
<Text style={styles.empty}> <Text style={styles.empty}>{error || 'No events assigned to your account.'}</Text>
{error || 'No events with attendance sessions.'}
</Text>
} }
/> />
)} )}
@ -140,38 +412,209 @@ export function EventsScreen({ navigation }: Props) {
); );
} }
const cardShadow = Platform.OS === 'ios' ? shadows.card.ios : shadows.card.android;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background }, wrap: { flex: 1, backgroundColor: colors.background },
logout: { color: 'rgba(255,255,255,0.85)', ...typography.small, fontWeight: '600' }, header: {
headerBottom: { paddingHorizontal: spacing.xl,
marginTop: 16, paddingBottom: spacing.lg,
overflow: 'hidden',
},
headerBg: {
...StyleSheet.absoluteFill,
backgroundColor: colors.primary,
zIndex: 0,
},
headerCircleA: {
position: 'absolute',
width: 128,
height: 128,
borderRadius: 64,
right: -30,
top: -30,
backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0,
},
headerCircleB: {
position: 'absolute',
width: 96,
height: 96,
borderRadius: 48,
left: '30%',
bottom: -20,
backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0,
},
headerTopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
zIndex: 1,
},
headerActions: { flexDirection: 'row', alignItems: 'center', gap: 14 },
logo: {
width: 60,
height: 31,
},
syncBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
syncText: {
color: 'rgba(255,255,255,0.9)',
...typography.small,
fontWeight: '600',
},
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
logoutText: {
color: 'rgba(255,255,255,0.85)',
...typography.small,
fontWeight: '600',
},
userRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 12,
zIndex: 1,
},
userText: { flex: 1, minWidth: 0 },
signedInLabel: {
color: 'rgba(255,255,255,0.85)',
fontSize: 12,
},
signedInEmail: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
marginTop: 2,
},
onlinePill: {
backgroundColor: 'rgba(34,197,94,0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: radii.pill,
},
offlinePill: {
backgroundColor: 'rgba(0,0,0,0.25)',
},
onlinePillText: {
color: '#fff',
fontSize: 11,
fontWeight: '700',
},
listHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
listHeaderTitle: {
...typography.h2,
color: colors.text,
},
listHeaderCount: {
...typography.small,
color: colors.muted,
},
list: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.xl,
},
emptyList: {
flexGrow: 1,
justifyContent: 'center',
padding: spacing.xl,
paddingTop: spacing.lg,
}, },
headerBottomText: { flex: 1 },
headerBottomLabel: { color: '#fff', ...typography.h2 },
headerBottomCount: { marginTop: 2, color: 'rgba(255,255,255,0.75)', ...typography.small },
list: { padding: spacing.lg, paddingTop: spacing.lg },
emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 },
center: { marginTop: 40 }, center: { marginTop: 40 },
cardPress: { marginBottom: 12 }, cardPress: { marginBottom: 12 },
card: { borderRadius: radii.xl, borderWidth: 0 }, card: {
borderRadius: radii.xl,
borderWidth: 0,
...cardShadow,
},
cardRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, cardRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
cardText: { flex: 1, minWidth: 0 }, cardText: { flex: 1, minWidth: 0 },
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '800', marginBottom: 8 }, cardTitle: {
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, color: colors.text,
cardMeta: { color: colors.muted, fontSize: 12, marginBottom: 4 }, fontSize: 15,
badges: { marginTop: 10 }, fontWeight: '700',
chev: { marginBottom: 10,
width: 34, },
height: 34, metaRow: {
borderRadius: 17, flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
marginBottom: 6,
},
cardMeta: { flex: 1, color: colors.muted, fontSize: 12, lineHeight: 18 },
locationMeta: { lineHeight: 18 },
badgeRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
},
sessionBadge: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: radii.pill,
backgroundColor: 'rgba(247,148,29,0.10)',
},
sessionBadgeText: {
color: colors.primaryDark,
fontSize: 12,
fontWeight: '600',
},
offlineBadge: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 7,
borderRadius: radii.pill,
backgroundColor: 'rgba(22,163,74,0.12)',
},
offlineBadgePending: {
backgroundColor: 'rgba(217,119,6,0.14)',
},
offlineBadgeText: {
color: '#166534',
fontSize: 12,
fontWeight: '600',
},
offlineBadgeTextPending: {
color: '#92400E',
},
cardSide: { alignItems: 'center', gap: 10 },
offlineAction: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(247,148,29,0.12)', backgroundColor: 'rgba(247,148,29,0.10)',
},
chev: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(247,148,29,0.10)',
}, },
chevText: { color: colors.primaryDark, fontSize: 22, lineHeight: 22, fontWeight: '700' },
empty: { textAlign: 'center', color: colors.muted, fontSize: 15 }, empty: { textAlign: 'center', color: colors.muted, fontSize: 15 },
}); });

View File

@ -1,9 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
ActivityIndicator,
KeyboardAvoidingView,
Platform, Platform,
Pressable, Pressable,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
View, View,
@ -42,17 +41,21 @@ export function Login2FAScreen({ navigation, route }: Props) {
}; };
return ( return (
<KeyboardAvoidingView <View style={styles.wrap}>
style={styles.wrap} <GradientHeader
behavior={Platform.OS === 'ios' ? 'padding' : undefined} title="Two-factor"
> subtitle={`Enter the code for ${email}`}
<View style={styles.inner}> style={styles.hero}
<GradientHeader />
title="Two-factor"
subtitle={`Enter the code for ${email}`}
style={styles.hero}
/>
<ScrollView
style={styles.scroll}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="never"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
contentContainerStyle={styles.scrollContent}
>
<Card style={styles.sheet} padded={false}> <Card style={styles.sheet} padded={false}>
<View style={styles.sheetInner}> <View style={styles.sheetInner}>
{error ? ( {error ? (
@ -79,21 +82,21 @@ export function Login2FAScreen({ navigation, route }: Props) {
</View> </View>
</View> </View>
</Card> </Card>
</View> </ScrollView>
</KeyboardAvoidingView> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background }, wrap: { flex: 1, backgroundColor: colors.background },
inner: { flex: 1 },
hero: { hero: {
paddingTop: 72, paddingTop: 72,
paddingBottom: 44, paddingBottom: 44,
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
}, },
scroll: { flex: 1 },
scrollContent: { flexGrow: 1 },
sheet: { sheet: {
flex: 1,
marginTop: -28, marginTop: -28,
borderTopLeftRadius: radii.xl, borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl, borderTopRightRadius: radii.xl,

View File

@ -1,14 +1,13 @@
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { import {
Image, Image,
KeyboardAvoidingView,
Platform, Platform,
Pressable, Pressable,
ScrollView, ScrollView,
StatusBar, StatusBar,
StyleSheet, StyleSheet,
Text, Text,
useWindowDimensions, TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -17,19 +16,21 @@ import { authApi } from '../api/auth';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, spacing, typography } from '../theme';
import { PrimaryButton, TextField } from '../components/ui'; import { AppIcon, PrimaryButton, TextField } from '../components/ui';
const FIELD_ICON_COLOR = '#9CA3AF';
type Props = NativeStackScreenProps<RootStackParamList, 'Login'>; type Props = NativeStackScreenProps<RootStackParamList, 'Login'>;
const BRAND_ORANGE = '#F7941D'; const BRAND_ORANGE = '#F7941D';
const BRAND_ORANGE_DARK = '#E8780A'; const BRAND_ORANGE_DARK = '#E8780A';
const BUTTON_HEIGHT = 52; /** Fixed height — do not tie to window size (keyboard changes dimensions and causes flicker). */
const BOTTOM_BAR_PAD_Y = 16; const HERO_BODY_HEIGHT = 240;
export function LoginScreen({ navigation }: Props) { export function LoginScreen({ navigation }: Props) {
const { refreshUser } = useAuth(); const { refreshUser } = useAuth();
const { height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const heroHeight = Math.round(windowHeight * 0.42); const passwordRef = useRef<TextInput>(null);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -74,12 +75,15 @@ export function LoginScreen({ navigation }: Props) {
<View style={styles.root}> <View style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor={BRAND_ORANGE} /> <StatusBar barStyle="light-content" backgroundColor={BRAND_ORANGE} />
{/* Orange hero — fixed pixel height so it always renders */} <View
<View style={[styles.hero, { height: heroHeight + insets.top, paddingTop: insets.top + 24 }]}> style={[
styles.hero,
{ height: HERO_BODY_HEIGHT + insets.top, paddingTop: insets.top + 20 },
]}
>
<View style={styles.heroGradientDark} /> <View style={styles.heroGradientDark} />
<View style={styles.heroCircleA} /> <View style={styles.heroCircleA} />
<View style={styles.heroCircleB} /> <View style={styles.heroCircleB} />
<View style={styles.logoCard}> <View style={styles.logoCard}>
<Image <Image
source={require('../assets/eventbuzz-logo.png')} source={require('../assets/eventbuzz-logo.png')}
@ -90,72 +94,71 @@ export function LoginScreen({ navigation }: Props) {
<Text style={styles.heroSubtitle}>Attendance Management</Text> <Text style={styles.heroSubtitle}>Attendance Management</Text>
</View> </View>
<KeyboardAvoidingView <ScrollView
style={styles.formArea} style={styles.formArea}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="never"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.sheetScrollContent,
{ paddingBottom: insets.bottom + spacing.xl },
]}
> >
<View style={styles.sheet}> <Text style={styles.title}>Welcome back</Text>
<ScrollView <Text style={styles.sub}>Sign in to your dashboard account</Text>
keyboardShouldPersistTaps="handled"
contentContainerStyle={[
styles.sheetScroll,
{
paddingBottom:
BUTTON_HEIGHT +
BOTTOM_BAR_PAD_Y * 2 +
Math.max(insets.bottom, 16) +
18,
},
]}
showsVerticalScrollIndicator={false}
>
<Text style={styles.title}>Welcome back</Text>
<Text style={styles.sub}>Sign in to your dashboard account</Text>
{error ? ( {error ? (
<View style={styles.errorBox}> <View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<View style={styles.fields}>
<TextField
label="Email address"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
placeholder="you@example.com"
left={<Text style={styles.fieldIcon}>✉</Text>}
/>
<TextField
label="Password"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPw}
placeholder="Your password"
left={<Text style={styles.fieldIcon}>🔒</Text>}
right={
<Pressable onPress={() => setShowPw((v) => !v)} hitSlop={10}>
<Text style={styles.fieldIcon}>{showPw ? '🙈' : '👁'}</Text>
</Pressable>
}
/>
</View>
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<PrimaryButton
title={loading ? 'Signing in…' : 'Sign In'}
loading={loading}
onPress={submit}
style={styles.signInBtn}
/>
</View> </View>
) : null}
<View style={styles.fields}>
<TextField
label="Email address"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
placeholder="you@example.com"
left={<AppIcon name="mail-outline" size={18} color={FIELD_ICON_COLOR} />}
/>
<TextField
ref={passwordRef}
label="Password"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPw}
returnKeyType="done"
onSubmitEditing={submit}
placeholder="Your password"
left={<AppIcon name="lock-closed-outline" size={18} color={FIELD_ICON_COLOR} />}
right={
<Pressable onPress={() => setShowPw((v) => !v)} hitSlop={10}>
<AppIcon
name={showPw ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={FIELD_ICON_COLOR}
/>
</Pressable>
}
/>
</View> </View>
</KeyboardAvoidingView>
<PrimaryButton
title={loading ? 'Signing in…' : 'Sign In'}
loading={loading}
onPress={submit}
style={styles.signInBtn}
/>
</ScrollView>
</View> </View>
); );
} }
@ -169,7 +172,7 @@ const styles = StyleSheet.create({
backgroundColor: BRAND_ORANGE, backgroundColor: BRAND_ORANGE,
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingBottom: 36, paddingBottom: 28,
overflow: 'hidden', overflow: 'hidden',
}, },
heroGradientDark: { heroGradientDark: {
@ -201,7 +204,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.10)', backgroundColor: 'rgba(255,255,255,0.10)',
}, },
logoCard: { logoCard: {
backgroundColor: '#fff', backgroundColor: 'rgba(255,255,255,0.92)',
borderRadius: 16, borderRadius: 16,
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 16, paddingVertical: 16,
@ -213,38 +216,30 @@ const styles = StyleSheet.create({
shadowRadius: 20, shadowRadius: 20,
elevation: 8, elevation: 8,
}, },
logo: { width: 144, height: 52 }, logo: { width: 140, height: 73 },
heroSubtitle: { heroSubtitle: {
color: 'rgba(255,255,255,0.85)', color: 'rgba(255,255,255,0.92)',
fontSize: 14, fontSize: 14,
fontWeight: '500',
letterSpacing: 0.4, letterSpacing: 0.4,
zIndex: 2, zIndex: 2,
marginBottom: 10
}, },
formArea: { formArea: {
flex: 1, flex: 1,
}, marginTop: -24,
sheet: {
flex: 1,
marginTop: -28,
backgroundColor: '#fff', backgroundColor: '#fff',
borderTopLeftRadius: 28, borderTopLeftRadius: 28,
borderTopRightRadius: 28, borderTopRightRadius: 28,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 12,
overflow: 'hidden',
}, },
sheetScroll: { sheetScrollContent: {
flexGrow: 1,
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
paddingTop: 32, paddingTop: 28,
paddingBottom: 16,
}, },
title: { ...typography.title, color: colors.text, marginBottom: 6 }, title: { ...typography.title, color: colors.text, marginBottom: 6 },
sub: { ...typography.body, color: colors.muted, marginBottom: 24 }, sub: { ...typography.body, color: colors.muted, marginBottom: 24 },
fields: { gap: 16 }, fields: { gap: 16 },
fieldIcon: { fontSize: 16, color: '#9CA3AF' },
errorBox: { errorBox: {
backgroundColor: 'rgba(220,38,38,0.08)', backgroundColor: 'rgba(220,38,38,0.08)',
borderWidth: 1, borderWidth: 1,
@ -256,20 +251,6 @@ const styles = StyleSheet.create({
}, },
errorText: { ...typography.body, color: colors.error }, errorText: { ...typography.body, color: colors.error },
signInBtn: { signInBtn: {
borderRadius: 14, marginTop: spacing.lg,
shadowColor: BRAND_ORANGE,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 16,
elevation: 6,
},
bottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: spacing.xl,
paddingTop: BOTTOM_BAR_PAD_Y,
backgroundColor: '#fff',
}, },
}); });

View File

@ -5,18 +5,31 @@ import {
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { attendancesApi } from '../api/attendances'; import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types'; import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal'; import { CheckInResultModal } from '../components/CheckInResultModal';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { performAppCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext';
import {
countPendingForSession,
isOfflinePackActive,
listOfflineParticipants,
} from '../offline/repos';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, spacing, typography } from '../theme';
import { Card, GradientHeader, PrimaryButton, TextField } from '../components/ui'; import { AppIcon, Card } from '../components/ui';
type Props = NativeStackScreenProps<RootStackParamList, 'Participants'>; type Props = NativeStackScreenProps<RootStackParamList, 'Participants'>;
const CHECKIN_GREEN = '#22C55E';
const AGAIN_BLUE = '#2563EB';
const NAVY_SCAN = '#1A1A2E';
function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean { function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean {
const needle = q.trim().toLowerCase(); const needle = q.trim().toLowerCase();
if (!needle) return true; if (!needle) return true;
@ -27,8 +40,96 @@ function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean
return hay.includes(needle); return hay.includes(needle);
} }
function getInitial(name: string) {
return (name.trim()[0] || '?').toUpperCase();
}
function formatCheckInTime(iso?: string | null) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
}
function ParticipantsHeader({
eventName,
sessionName,
checkedInCount,
totalCount,
onBack,
}: {
eventName: string;
sessionName: string;
checkedInCount: number;
totalCount: number;
onBack: () => void;
}) {
const insets = useSafeAreaInsets();
return (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<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.headerMain}>
<View style={styles.headerTextCol}>
<Text style={styles.headerTitle} numberOfLines={2}>
{sessionName}
</Text>
<Text style={styles.headerSubtitle}>
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>
</View>
</View>
);
}
function SearchBar({
value,
onChangeText,
}: {
value: string;
onChangeText: (text: string) => void;
}) {
return (
<View style={styles.searchStrip}>
<View style={styles.searchBox}>
<AppIcon name="search-outline" size={16} color="#9CA3AF" />
<TextInput
value={value}
onChangeText={onChangeText}
placeholder="Filter by name, email, phone..."
placeholderTextColor="#9CA3AF"
style={styles.searchInput}
autoCapitalize="none"
autoCorrect={false}
/>
{value.length > 0 ? (
<Pressable onPress={() => onChangeText('')} hitSlop={8}>
<AppIcon name="close-circle-outline" size={16} color="#9CA3AF" />
</Pressable>
) : null}
</View>
</View>
);
}
export function ParticipantsScreen({ navigation, route }: Props) { export function ParticipantsScreen({ navigation, route }: Props) {
const { eventId, attendanceId, attendanceName, allowDuplicate } = route.params; const { eventId, eventName, attendanceId, attendanceName, allowDuplicate } = route.params;
const insets = useSafeAreaInsets();
const { isOnline, refreshStatuses } = useOffline();
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]); const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
const [searchQ, setSearchQ] = useState(''); const [searchQ, setSearchQ] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -36,18 +137,46 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AttendanceCheckInResult | null>(null); const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
const [resultError, setResultError] = useState<string | null>(null); const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [fromCache, setFromCache] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
const res = await attendancesApi.searchParticipants(eventId, attendanceId); const packActive = await isOfflinePackActive(eventId);
if (res.error) { const pending = packActive ? await countPendingForSession(attendanceId) : 0;
setError(res.error);
setAll([]); // Prefer local roster when offline, or when unsynced outbox would diverge from server.
} else { if (packActive && (!isOnline || pending > 0)) {
setAll(res.data?.participants ?? []); const local = await listOfflineParticipants(attendanceId);
setAll(local);
setFromCache(true);
return;
} }
}, [eventId, attendanceId]);
if (isOnline) {
const res = await attendancesApi.searchParticipants(eventId, attendanceId);
if (!res.error && res.data) {
setAll(res.data.participants ?? []);
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);
return;
}
setError('You are offline. Enable Offline for this event first.');
setAll([]);
setFromCache(false);
}, [eventId, attendanceId, isOnline]);
useEffect(() => { useEffect(() => {
const unsub = navigation.addListener('focus', () => { const unsub = navigation.addListener('focus', () => {
@ -66,111 +195,181 @@ export function ParticipantsScreen({ navigation, route }: Props) {
[all, searchQ], [all, searchQ],
); );
const checkedInCount = useMemo(
() => all.filter((p) => p.alreadyCheckedIn).length,
[all],
);
const checkIn = async (participantId: string) => { const checkIn = async (participantId: string) => {
setBusyId(participantId); setBusyId(participantId);
setResultError(null); setResultError(null);
const res = await attendancesApi.checkIn(eventId, attendanceId, participantId); setPendingSync(false);
const outcome = await performAppCheckIn({
eventId,
attendanceId,
attendanceName,
participantId,
});
setBusyId(null); setBusyId(null);
if (res.error) { if (!outcome.ok) {
setResult(null); setResult(null);
setResultError(res.error); setResultError(outcome.error);
setModalVisible(true); setModalVisible(true);
return; return;
} }
setResult(res.data ?? null); setResult(outcome.result);
setPendingSync(outcome.pendingSync);
setResultError(null); setResultError(null);
setModalVisible(true); setModalVisible(true);
await load(); await load();
await refreshStatuses();
}; };
const closeModal = () => { const closeModal = () => {
setModalVisible(false); setModalVisible(false);
setResult(null); setResult(null);
setResultError(null); setResultError(null);
setPendingSync(false);
};
const listBottomPad = Math.max(insets.bottom, 16) + 88;
const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => {
const canCheckIn = !item.alreadyCheckedIn || allowDuplicate;
const isAgain = item.alreadyCheckedIn && allowDuplicate;
const showAction = canCheckIn;
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
style={[
styles.avatar,
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
]}
>
<Text
style={[
styles.avatarText,
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
]}
>
{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>
) : null}
</View>
</Card>
);
}; };
return ( return (
<View style={styles.wrap}> <View style={styles.wrap}>
<GradientHeader <ParticipantsHeader
title={attendanceName} eventName={eventName}
subtitle={ sessionName={attendanceName}
searchQ.trim() checkedInCount={checkedInCount}
? `${displayed.length} of ${all.length} shown` totalCount={all.length}
: `${all.length} participant${all.length === 1 ? '' : 's'}` onBack={() => navigation.goBack()}
}
style={styles.header}
bottom={
<TextField
value={searchQ}
onChangeText={setSearchQ}
placeholder="Filter by name, email, phone…"
containerStyle={styles.searchContainer}
/>
}
/> />
<SearchBar value={searchQ} onChangeText={setSearchQ} />
{fromCache ? (
<View style={styles.cacheBanner}>
<AppIcon name="cloud-offline-outline" size={14} color="#92400E" />
<Text style={styles.cacheBannerText}>Offline roster</Text>
</View>
) : null}
<View style={styles.countStrip}>
<Text style={styles.countText}>
{displayed.length} participant{displayed.length === 1 ? '' : 's'}
</Text>
</View>
{loading ? ( {loading ? (
<ActivityIndicator style={styles.center} color={colors.primary} /> <ActivityIndicator style={styles.center} color={colors.primary} />
) : ( ) : (
<FlatList <FlatList
data={displayed} data={displayed}
keyExtractor={(item) => item._id} keyExtractor={(item) => item._id}
contentContainerStyle={displayed.length === 0 ? styles.emptyList : styles.list} renderItem={renderItem}
ListEmptyComponent={ contentInsetAdjustmentBehavior="never"
<Text style={styles.empty}>{error || 'No participants found.'}</Text> contentContainerStyle={
displayed.length === 0
? [styles.emptyList, { paddingBottom: listBottomPad }]
: [styles.list, { paddingBottom: listBottomPad }]
}
ListEmptyComponent={
<View style={styles.emptyWrap}>
<AppIcon name="search-outline" size={32} color="#D1D5DB" />
<Text style={styles.empty}>{error || 'No participants found'}</Text>
</View>
} }
renderItem={({ item }) => {
const canCheckIn = !item.alreadyCheckedIn || allowDuplicate;
return (
<Card style={styles.rowCard} padded={false}>
<View style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.name}>{item.displayName}</Text>
<Text style={styles.meta}>
{[item.email, item.phone].filter(Boolean).join(' · ') || '—'}
</Text>
{item.alreadyCheckedIn ? (
<Text style={styles.checked}>Already checked in</Text>
) : null}
</View>
<Pressable
style={[styles.checkBtn, !canCheckIn && styles.checkBtnOff]}
disabled={!canCheckIn || busyId === item._id}
onPress={() => checkIn(item._id)}
>
{busyId === item._id ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.checkBtnText}>
{item.alreadyCheckedIn && allowDuplicate ? 'Again' : 'Check in'}
</Text>
)}
</Pressable>
</View>
</Card>
);
}}
/> />
)} )}
<View style={styles.bottomBar}> <Pressable
<PrimaryButton style={[styles.scanFab, { bottom: Math.max(insets.bottom, 16) + 8 }]}
title="Scan QR" onPress={() =>
onPress={() => navigation.navigate('QrScanner', {
navigation.navigate('QrScanner', { eventId,
eventId, attendanceId,
attendanceId, attendanceName,
attendanceName, allowDuplicate,
allowDuplicate, })
}) }
} >
/> <AppIcon name="qr-code-outline" size={18} color="#fff" />
</View> <Text style={styles.scanFabText}>Scan QR</Text>
</Pressable>
<CheckInResultModal <CheckInResultModal
visible={modalVisible} visible={modalVisible}
result={result} result={result}
error={resultError} error={resultError}
pendingSync={pendingSync}
onClose={closeModal} onClose={closeModal}
/> />
</View> </View>
@ -179,32 +378,234 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background }, wrap: { flex: 1, backgroundColor: colors.background },
header: { paddingBottom: 18 }, header: {
searchContainer: { marginTop: 14 }, paddingHorizontal: spacing.xl,
list: { paddingHorizontal: spacing.lg, paddingBottom: 120, paddingTop: spacing.lg }, paddingBottom: spacing.lg,
emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 }, overflow: 'hidden',
center: { marginTop: 32 },
rowCard: { marginBottom: 10, borderRadius: radii.xl, borderWidth: 0 },
row: { flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
rowText: { flex: 1, marginRight: 10 },
name: { color: colors.text, fontSize: 15, fontWeight: '800' },
meta: { ...typography.small, color: colors.muted, marginTop: 4 },
checked: { ...typography.small, color: colors.success, marginTop: 6, fontWeight: '700' },
checkBtn: {
backgroundColor: colors.primaryDark,
paddingHorizontal: 14,
paddingVertical: 11,
borderRadius: radii.md,
minWidth: 88,
alignItems: 'center',
}, },
checkBtnOff: { backgroundColor: colors.disabled }, headerBg: {
checkBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 }, ...StyleSheet.absoluteFill,
empty: { textAlign: 'center', color: colors.muted }, backgroundColor: colors.primary,
bottomBar: { zIndex: 0,
},
headerCircle: {
position: 'absolute',
width: 128,
height: 128,
borderRadius: 64,
right: -30,
top: -30,
backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0,
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: spacing.md,
alignSelf: 'flex-start',
maxWidth: '85%',
zIndex: 1,
},
backLabel: {
color: 'rgba(255,255,255,0.85)',
fontSize: 14,
fontWeight: '500',
flexShrink: 1,
},
headerMain: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
zIndex: 1,
},
headerTextCol: { flex: 1, minWidth: 0 },
headerTitle: {
color: '#fff',
fontSize: 18,
fontWeight: '700',
marginBottom: 2,
},
headerSubtitle: {
color: 'rgba(255,255,255,0.90)',
fontSize: 12,
},
checkedBadge: {
backgroundColor: 'rgba(255,255,255,0.20)',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 8,
alignItems: 'center',
minWidth: 64,
},
checkedBadgeCount: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
lineHeight: 24,
},
checkedBadgeLabel: {
color: 'rgba(255,255,255,0.95)',
fontSize: 11,
},
cacheBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(217,119,6,0.12)',
paddingHorizontal: spacing.lg,
paddingVertical: 8,
},
cacheBannerText: {
color: '#92400E',
fontSize: 12,
fontWeight: '600',
},
searchStrip: {
backgroundColor: '#fff',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
searchBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 10,
},
searchInput: {
flex: 1,
fontSize: 14,
color: colors.text,
paddingVertical: 0,
},
countStrip: {
backgroundColor: '#fff',
paddingHorizontal: spacing.lg,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
countText: {
fontSize: 12,
color: colors.muted,
},
list: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
emptyList: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
center: { marginTop: 32 },
rowCard: {
marginBottom: 10,
borderRadius: radii.xl,
borderWidth: 0,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatar: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
avatarChecked: {
backgroundColor: CHECKIN_GREEN,
},
avatarPending: {
backgroundColor: '#E2E8F0',
},
avatarText: {
fontSize: 14,
fontWeight: '700',
},
avatarTextChecked: {
color: '#fff',
},
avatarTextPending: {
color: '#64748B',
},
rowText: { flex: 1, minWidth: 0, marginRight: 4 },
name: { color: colors.text, fontSize: 14, fontWeight: '600' },
meta: { ...typography.small, color: '#9CA3AF', marginTop: 2, fontSize: 12 },
checkedRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 4,
},
checked: {
fontSize: 12,
color: CHECKIN_GREEN,
fontWeight: '500',
},
checkBtn: {
paddingHorizontal: 12,
paddingVertical: 9,
borderRadius: 12,
minWidth: 78,
alignItems: 'center',
justifyContent: 'center',
},
checkBtnPrimary: {
backgroundColor: colors.primaryDark,
},
checkBtnAgain: {
backgroundColor: AGAIN_BLUE,
},
checkBtnOff: {
backgroundColor: colors.disabled,
},
checkBtnInner: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
checkBtnText: {
color: '#fff',
fontWeight: '600',
fontSize: 12,
},
emptyWrap: {
alignItems: 'center',
gap: 12,
},
empty: { textAlign: 'center', color: colors.muted, fontSize: 14 },
scanFab: {
position: 'absolute', position: 'absolute',
left: spacing.lg,
right: spacing.lg, right: spacing.lg,
bottom: 18, flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: NAVY_SCAN,
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 18,
shadowColor: NAVY_SCAN,
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.45,
shadowRadius: 12,
elevation: 8,
},
scanFabText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
}, },
}); });

View File

@ -1,5 +1,13 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
useWindowDimensions,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { import {
Camera, Camera,
@ -7,24 +15,112 @@ import {
useCameraPermission, useCameraPermission,
useCodeScanner, useCodeScanner,
} from 'react-native-vision-camera'; } from 'react-native-vision-camera';
import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult } from '../api/types'; import type { AttendanceCheckInResult } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal'; import { CheckInResultModal } from '../components/CheckInResultModal';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { performAppCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext';
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr'; import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, spacing, typography } from '../theme';
import { PrimaryButton } from '../components/ui'; import { PrimaryButton } from '../components/ui';
type Props = NativeStackScreenProps<RootStackParamList, 'QrScanner'>; type Props = NativeStackScreenProps<RootStackParamList, 'QrScanner'>;
const DIM_OVERLAY = 'rgba(0,0,0,0.55)';
const CORNER_LEN = 28;
const CORNER_WIDTH = 4;
const SCAN_GAP_BELOW_HEADER = 28;
type CornerPosition = 'tl' | 'tr' | 'bl' | 'br';
function ScanCorner({ position }: { position: CornerPosition }) {
const cornerStyle =
position === 'tl'
? styles.cornerTL
: position === 'tr'
? styles.cornerTR
: position === 'bl'
? styles.cornerBL
: styles.cornerBR;
return <View style={[styles.corner, cornerStyle]} />;
}
function ScanFrameOverlay({
size,
scanTop,
screenWidth,
screenHeight,
}: {
size: number;
scanTop: number;
screenWidth: number;
screenHeight: number;
}) {
const scanLeft = Math.max(0, (screenWidth - size) / 2);
const scanBottom = scanTop + size;
const bottomHeight = Math.max(0, screenHeight - scanBottom);
const rightWidth = Math.max(0, screenWidth - scanLeft - size);
return (
<View
style={[styles.scanOverlay, { width: screenWidth, height: screenHeight }]}
pointerEvents="none"
>
<View
style={[styles.dimAbsolute, { top: 0, left: 0, width: screenWidth, height: scanTop }]}
/>
<View
style={[
styles.dimAbsolute,
{ top: scanBottom, left: 0, width: screenWidth, height: bottomHeight },
]}
/>
<View
style={[
styles.dimAbsolute,
{ top: scanTop, left: 0, width: scanLeft, height: size },
]}
/>
<View
style={[
styles.dimAbsolute,
{ top: scanTop, left: scanLeft + size, width: rightWidth, height: size },
]}
/>
<View
style={[
styles.scanWindow,
{ top: scanTop, left: scanLeft, width: size, height: size },
]}
>
<ScanCorner position="tl" />
<ScanCorner position="tr" />
<ScanCorner position="bl" />
<ScanCorner position="br" />
</View>
</View>
);
}
export function QrScannerScreen({ navigation, route }: Props) { export function QrScannerScreen({ navigation, route }: Props) {
const insets = useSafeAreaInsets();
const { width: screenWidth, height: windowHeight } = useWindowDimensions();
const [layoutHeight, setLayoutHeight] = useState(0);
const screenHeight = layoutHeight || windowHeight;
const scanSize = Math.min(Math.round(screenWidth * 0.72), 280);
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, attendanceId, attendanceName } = route.params;
const { refreshStatuses } = useOffline();
const { hasPermission, requestPermission } = useCameraPermission(); const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back'); const device = useCameraDevice('back');
const scannedRef = useRef(false); const scannedRef = useRef(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [result, setResult] = useState<AttendanceCheckInResult | null>(null); const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
const [resultError, setResultError] = useState<string | null>(null); const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const handleScan = useCallback( const handleScan = useCallback(
@ -35,6 +131,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
scannedRef.current = true; scannedRef.current = true;
setResult(null); setResult(null);
setResultError(parsed.error); setResultError(parsed.error);
setPendingSync(false);
setModalVisible(true); setModalVisible(true);
return; return;
} }
@ -42,20 +139,29 @@ export function QrScannerScreen({ navigation, route }: Props) {
scannedRef.current = true; scannedRef.current = true;
setProcessing(true); setProcessing(true);
setResultError(null); setResultError(null);
setPendingSync(false);
const res = await attendancesApi.checkIn(eventId, attendanceId, parsed.participantId); const outcome = await performAppCheckIn({
eventId,
attendanceId,
attendanceName,
participantId: parsed.participantId,
});
setProcessing(false); setProcessing(false);
if (res.error) { if (!outcome.ok) {
setResult(null); setResult(null);
setResultError(res.error); setResultError(outcome.error);
setPendingSync(false);
} else { } else {
setResult(res.data ?? null); setResult(outcome.result);
setResultError(null); setResultError(null);
setPendingSync(outcome.pendingSync);
refreshStatuses().catch(() => undefined);
} }
setModalVisible(true); setModalVisible(true);
}, },
[eventId, attendanceId, processing], [eventId, attendanceId, attendanceName, processing, refreshStatuses],
); );
const codeScanner = useCodeScanner({ const codeScanner = useCodeScanner({
@ -70,6 +176,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
setModalVisible(false); setModalVisible(false);
setResult(null); setResult(null);
setResultError(null); setResultError(null);
setPendingSync(false);
scannedRef.current = false; scannedRef.current = false;
if (result && !resultError) { if (result && !resultError) {
navigation.goBack(); navigation.goBack();
@ -102,15 +209,30 @@ export function QrScannerScreen({ navigation, route }: Props) {
} }
return ( return (
<View style={styles.wrap}> <View
style={styles.wrap}
onLayout={(e) => setLayoutHeight(e.nativeEvent.layout.height)}
>
<Camera <Camera
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
device={device} device={device}
isActive={!modalVisible} isActive={!modalVisible}
codeScanner={codeScanner} codeScanner={codeScanner}
/> />
<View style={styles.overlay}> <ScanFrameOverlay
<View style={styles.topPill}> size={scanSize}
scanTop={scanTop}
screenWidth={screenWidth}
screenHeight={screenHeight}
/>
<View style={[styles.overlay, { top: headerTop }]}>
<View
style={styles.topPill}
onLayout={(e) => {
const { y, height } = e.nativeEvent.layout;
setHeaderBottom(headerTop + y + height);
}}
>
<Text style={styles.title}>{attendanceName}</Text> <Text style={styles.title}>{attendanceName}</Text>
<Text style={styles.hint}>Point at participant badge QR</Text> <Text style={styles.hint}>Point at participant badge QR</Text>
{processing ? ( {processing ? (
@ -118,7 +240,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
) : null} ) : null}
</View> </View>
</View> </View>
<View style={styles.bottom}> <View style={[styles.bottom, { bottom: Math.max(insets.bottom, 22) }]}>
<Pressable style={styles.cancel} onPress={() => navigation.goBack()}> <Pressable style={styles.cancel} onPress={() => navigation.goBack()}>
<Text style={styles.cancelText}>Cancel</Text> <Text style={styles.cancelText}>Cancel</Text>
</Pressable> </Pressable>
@ -128,6 +250,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
visible={modalVisible} visible={modalVisible}
result={result} result={result}
error={resultError} error={resultError}
pendingSync={pendingSync}
onClose={closeModal} onClose={closeModal}
/> />
</View> </View>
@ -136,12 +259,62 @@ export function QrScannerScreen({ navigation, route }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: '#000' }, wrap: { flex: 1, backgroundColor: '#000' },
scanOverlay: {
position: 'absolute',
top: 0,
left: 0,
zIndex: 1,
},
dimAbsolute: {
position: 'absolute',
backgroundColor: DIM_OVERLAY,
},
scanWindow: {
position: 'absolute',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.35)',
borderRadius: 4,
},
corner: {
position: 'absolute',
width: CORNER_LEN,
height: CORNER_LEN,
borderColor: '#fff',
},
cornerTL: {
top: -1,
left: -1,
borderTopWidth: CORNER_WIDTH,
borderLeftWidth: CORNER_WIDTH,
borderTopLeftRadius: 6,
},
cornerTR: {
top: -1,
right: -1,
borderTopWidth: CORNER_WIDTH,
borderRightWidth: CORNER_WIDTH,
borderTopRightRadius: 6,
},
cornerBL: {
bottom: -1,
left: -1,
borderBottomWidth: CORNER_WIDTH,
borderLeftWidth: CORNER_WIDTH,
borderBottomLeftRadius: 6,
},
cornerBR: {
bottom: -1,
right: -1,
borderBottomWidth: CORNER_WIDTH,
borderRightWidth: CORNER_WIDTH,
borderBottomRightRadius: 6,
},
overlay: { overlay: {
position: 'absolute', position: 'absolute',
top: 48,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', alignItems: 'center',
zIndex: 2,
}, },
topPill: { topPill: {
width: '92%', width: '92%',
@ -160,7 +333,7 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
left: spacing.lg, left: spacing.lg,
right: spacing.lg, right: spacing.lg,
bottom: 22, zIndex: 2,
}, },
cancel: { cancel: {
paddingHorizontal: 24, paddingHorizontal: 24,