diff --git a/App.tsx b/App.tsx
index 78df64d..1c29e24 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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 Ionicons from 'react-native-vector-icons/Ionicons';
import { AuthProvider } from './src/context/AuthContext';
+import { OfflineProvider } from './src/offline/OfflineContext';
import { RootNavigator } from './src/navigation/RootNavigator';
function App() {
+ useEffect(() => {
+ void Ionicons.loadFont();
+ }, []);
+
return (
+ {Platform.OS === 'ios' ? : null}
-
+
+
+
);
diff --git a/README.md b/README.md
index 8fcd0cb..96d0197 100644
--- a/README.md
+++ b/README.md
@@ -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.
+## 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
Copy `.env.example` to `.env` and set `API_BASE_URL`:
diff --git a/app.json b/app.json
index 0734868..da9bad1 100644
--- a/app.json
+++ b/app.json
@@ -1,4 +1,4 @@
{
"name": "EventBuzzCheckins",
- "displayName": "EventBuzzCheckins"
+ "displayName": "EventBuzz Checkins"
}
diff --git a/index.js b/index.js
index 9b73932..9be83ef 100644
--- a/index.js
+++ b/index.js
@@ -2,6 +2,7 @@
* @format
*/
+import 'react-native-get-random-values';
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 458c842..00d31c4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -25,6 +25,28 @@ PODS:
- hermes-engine (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)
- RCTRequired (0.85.3)
- RCTSwiftUI (0.85.3)
@@ -1448,6 +1470,50 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- 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):
- hermes-engine
- RCTRequired
@@ -1949,6 +2015,28 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- 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/Core (= 4.7.3)
- VisionCamera/React (= 4.7.3)
@@ -1961,6 +2049,7 @@ DEPENDENCIES:
- "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- 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`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- 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-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)
- 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-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-networking (from `../node_modules/react-native/ReactCommon/react/networking`)
@@ -2036,6 +2127,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- RNScreens (from `../node_modules/react-native-screens`)
+ - RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- VisionCamera (from `../node_modules/react-native-vision-camera`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -2047,6 +2139,8 @@ EXTERNAL SOURCES:
hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-v250829098.0.10
+ op-sqlite:
+ :path: "../node_modules/@op-engineering/op-sqlite"
RCTDeprecation:
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired:
@@ -2123,6 +2217,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
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:
:path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple:
@@ -2195,92 +2293,98 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RNScreens:
:path: "../node_modules/react-native-screens"
+ RNVectorIcons:
+ :path: "../node_modules/react-native-vector-icons"
VisionCamera:
:path: "../node_modules/react-native-vision-camera"
Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- AsyncStorage: fb007da3d53332311126c6ce64e23765a04ecbe7
+ AsyncStorage: 097bd39cbb74dbfdeda2395deafdc09ed84561e3
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
hermes-engine: b8f76204869359f8bed36636ea3d6e542f026963
+ op-sqlite: 1cd6211ea2d1986b07b11a26c1eda3e51e53c260
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
- RCTSwiftUIWrapper: 7b8a1ffba8994a8f955c563511bffb74b4681317
+ RCTSwiftUIWrapper: 966ca7f5f22ac0b2b2255fb09cffc381f5440b03
RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
- React-Core: f90d375d3bab515ad00df30605ce1bf02e6db12f
+ React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
React-Core-prebuilt: 10f1a4faa8ebc8f2af5b577db5abd3e89e66ffa8
- React-CoreModules: da4f80202ef954bdfb926ca4c9ac5f685900aa5c
- React-cxxreact: 1d1d2a28a5f10e4e2e7cf2bfd54dc9c92c68c672
+ React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
+ React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
- React-defaultsnativemodule: 172d2ef7d11c531c40ee74f3be1ac98d258a817f
- React-domnativemodule: 5a60a88075a35e20fa5879c94e7aa24e5f4071d0
- React-Fabric: 5c1954e06c094623e46d51da2dc2c926621c03a5
- React-FabricComponents: f32494150868073accd5d52d46b30a0496626813
- React-FabricImage: dfcd2826b10f05c19e5bfa68d4937c4401c129db
- React-featureflags: 84bcfcb8f91f9475e437f3dd579399085a4af51e
- React-featureflagsnativemodule: 83111c4ee188b8859aaa8643c1be640442098fef
- React-graphics: e0441bc695f5c682a3fb1b0fd317e10765700f12
- React-hermes: da795ff5d5816d8940fca927c46dcdd81d7e9ad6
- React-idlecallbacksnativemodule: b6d79e1c9e335564e17d18e2649fe7524c4e74e4
- React-ImageManager: 0e137d7231092ddaa236f3520b67b4aa833e7079
- React-intersectionobservernativemodule: 160b3c85bb5a3df2bf50e60619c0db50aadbef8c
- React-jserrorhandler: 619d382632f5ed166541c03f7ba7deee76fb1b16
- React-jsi: eac528e72d25146faa922ef1aad82d338addbb75
- React-jsiexecutor: 2d3000fdde95d0da451f8976cdf9018448756023
- React-jsinspector: 0377987f9635c64c5aaf72ec73aaf2b0b0da7744
- React-jsinspectorcdp: 9db641a9cd0dd5b693df27d2e665ac2540ddc336
- React-jsinspectornetwork: 61b00d0adfe6f175830660f1b98da576bc74eb0a
- React-jsinspectortracing: 0f8d4f2ebd7523aece78cbc926cd4b6f2fc227dc
- React-jsitooling: 36f3854063d507bc4d4a01227ebf1b63d9e24592
- React-jsitracing: cddf044c0c2847d3f5822a984018fd5596c1d448
- React-logger: 57001aefabd79d885589d2f31a8be05ccc393eaa
- React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71
- React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21
- React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741
- react-native-config: 93721be89c97d983d5a90e2f3e692c8ff1d95720
- react-native-safe-area-context: c1eb308f4b36372a4de4b3bdaa8ed695ec3dd461
- React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56
- React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e
+ React-defaultsnativemodule: cd64bc09d7ca24112bbaf1b91edbbcf3d81ea7dc
+ React-domnativemodule: dacf5bc055ae041039574f38b73a20b91e368774
+ React-Fabric: bb0baa33d91839631d315800eb23e9aaa4338a44
+ React-FabricComponents: c504d0b0e2f3054b2ba19af839f175cb361153c0
+ React-FabricImage: 91eaea1cc58d25ae2596a9277bcfe028f92374c3
+ React-featureflags: 5ac0455da0af12ca79b40402e2f42c5c7556b638
+ React-featureflagsnativemodule: df7da181b064f10f5959a7cd529b5aab3686ecb2
+ React-graphics: d25b1195baf24c7918543f4aff9be89cc080906f
+ React-hermes: 663286153a8ad6bf752b742654c766ff5e8991e7
+ React-idlecallbacksnativemodule: 0bd5392cb67f1ab25df736814b7c05213b1d3c68
+ React-ImageManager: a03eed3e3d4222130dc0ad503a1a5f3aa89de746
+ React-intersectionobservernativemodule: 5d0f1c3c7b30031b0f6f730ee52dd9d607e2c966
+ React-jserrorhandler: d5d6e7e20c5a2d6e8607e18d31d8712d7de676f6
+ React-jsi: eb7cc4cffcf24796cc302d5b2bca0e92544139a9
+ React-jsiexecutor: 65006f60e64c72c6b82f62ef6bd17c84846e73f8
+ React-jsinspector: 01e32e2247b2486117fbb143db7f7717ef462c6d
+ React-jsinspectorcdp: f1cbb34ca41d188ba22efd9b663cf258a911f6cd
+ React-jsinspectornetwork: f61acc94c881c41451f508abfe6efa748b956c21
+ React-jsinspectortracing: 9395894d9bd4d17931b9afcf230c0e7f4cb3d674
+ React-jsitooling: 03ead841daa12a93b18479f5e400ceab3732d36d
+ React-jsitracing: 4ae61c79e14360d1c6ab566031c62c490da78439
+ React-logger: bf149dea4343a9037b74bade36cced8b63f03f46
+ React-Mapbuffer: fec3e025f0ffba6b32cd2a1d7bbdee3e269aae90
+ React-microtasksnativemodule: ab33a818d339f5a1da308893c11b487be66121a8
+ React-mutationobservernativemodule: a42d1626651ccd7d0dc02a56e69d4ec77c248893
+ react-native-config: 6188f674e4f4b3bdfd43c22886fb04f3103793ff
+ react-native-get-random-values: 68792987aef40e8aa72dc448d97e009d1f440c88
+ react-native-netinfo: 2f906d5d9a639090ed1c079135d6485a95f749f1
+ react-native-safe-area-context: fb5c8ee9f6dd62ef710611b3d370c501f42a4ac0
+ React-NativeModulesApple: deba264b03bd79c6bd61014fa30e40321b5e443a
+ React-networking: 35e6070b084f435429f85c5db40b4d5b38652fe9
React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e
- React-perflogger: b2b0144e4ba8dd157c1e90f8ebb02d22edbd040d
- React-performancecdpmetrics: 5a715ec33f2dea48a93a70db37a78b4f51f9fd5a
- React-performancetimeline: c292077a6fbc7f423269b01aab0fb7cc48efe3c0
+ React-perflogger: ca04287f205086a1edb5c95882be7b6068458889
+ React-performancecdpmetrics: cf1d0a3178ccd59353cedcacbda421f40100a889
+ React-performancetimeline: c9771212e7a43032d6f8d5edfb58280d46a7ce1f
React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884
- React-RCTAnimation: 1f9a58c7b74c25ea4ca6e6fbd05f37cc4919097a
- React-RCTAppDelegate: 856f82c57b47c1795009af585cfb7b87fe2d3b5a
- React-RCTBlob: 1cb18861a19be0b4d7e44bed9315e791413399f9
- React-RCTFabric: 081853d9efb9b38fa868c1ff3d60e48106fe2a24
- React-RCTFBReactNativeSpec: cbc760c7a889bfce20746ca0037b97c10ea58665
- React-RCTImage: 0d94b22644ca87fcb778a8fa3ffbf990bc9028df
- React-RCTLinking: 881a0c6772dd05a14ab0edfea05dadb698ec8090
- React-RCTNetwork: feb412861e3fff3426eb0961c2acdd96bee8dce1
- React-RCTRuntime: c61b848ffadc0209fe643406370d58021bd3585d
- React-RCTSettings: b6e14b8764f807ebe9be2e7f0d72be83b359ac26
- React-RCTText: 1ecd4f85ff609183ebec858cf94c0f27a9dec163
- React-RCTVibration: 3611aa5cb59094632b3141d72c50cbb8ce3d5b08
+ React-RCTAnimation: 343147a9cd68c93d0ca280799fedfc7102d76ce4
+ React-RCTAppDelegate: 5054754e92aaa9f8bfabe0f1022b84e46f3dcb57
+ React-RCTBlob: 8c7ae3422ca4e72bc64b7a0142fd730efc5d4dfb
+ React-RCTFabric: be458db054b206c4d8e4f20f666e75d5f2c2d420
+ React-RCTFBReactNativeSpec: 06db2e8d0f352d9fa23321aed1dd2cde25a3e83c
+ React-RCTImage: 481457bca63e039eb997f7d16c7560472f49657e
+ React-RCTLinking: 76cbb871240cec2dc5e7aa26c60f59e0ebbcf5a2
+ React-RCTNetwork: e23a778225b7672e38545d3c5c24e1f4aad6d15f
+ React-RCTRuntime: 93c830b3ab3f7b494bbe7ae7289784f8b07b3947
+ React-RCTSettings: 96196b535bef147381f96cc60ce9bda85d8be848
+ React-RCTText: 749ebbd1a999fd84d80f37002ea3bf597fcea6df
+ React-RCTVibration: 5b41a7f274757c2928845981d970916ef9e4ca14
React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f
- React-renderercss: d4a70898381431dcc07d6deba94a7eaef0cc9058
- React-rendererdebug: ad92235a960983f69183e2e59e2bce21e72c80a0
- React-RuntimeApple: 53a5b8fe60b044ec6fd4d254d66b7da69314a295
- React-RuntimeCore: 97e125471c0b8313db2bbeb1e9dc001a5503e979
- React-runtimeexecutor: 0ff42dcf1cc4bc7a89d29532a16a7d2d3849fb2f
- React-RuntimeHermes: da5a1b7e39f3db1a7b3303d75d4c5bb6cd7c0d49
- React-runtimescheduler: 0e24c80f0c8b6e822a33e4ad89e0ab555704eedb
- React-timing: f23e82ad46673716e34a786048414ab80f237594
- React-utils: 2851415c698da4b18331f9a445825d260fffa32c
- React-webperformancenativemodule: 9f29ed34f6f6c01dac688b95fd2dfcbccf3aed7a
- ReactAppDependencyProvider: a54c0c9b976766e1b6d5e2bb4d1ad0d15913e9e1
- ReactCodegen: 9af5798e943781fd2318dab7b60f25337388047c
- ReactCommon: 3ec2a55999d296af90c24beb66c8a77dc660d3ef
+ React-renderercss: 80eb778756fed511d6128fc005188ac9008b0baf
+ React-rendererdebug: b46f338fb9d3f0bea6cf0621016c6c5a7a18e72e
+ React-RuntimeApple: c494b2089fad4a0c553cf63c2bb265f7eca2285d
+ React-RuntimeCore: b0bb151c3e2b26c6309d45d05c54aa65a6e0c094
+ React-runtimeexecutor: 00b18635b6216a1708f6eb35dbadfd993ec91c7b
+ React-RuntimeHermes: bb44c4c574ce1b9507cad2e6be015344d18b94a9
+ React-runtimescheduler: e1631e57209cb94b3efc29002b6a049cac3f6599
+ React-timing: 356b88317ca60d373b0d94b6e7a71b0a572899f5
+ React-utils: ccc01da318979af773259c4f6cdb1876f6f86f1a
+ React-webperformancenativemodule: f8b97c2cb6cfa94e92a503c09ad6d491c50a1390
+ ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2
+ ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014
+ ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeDependencies: 8c3f5cf0bb3f36f1832b653f5689626379a12766
- RNScreens: b612be2401172d6666143db20a0f70f638c4619b
- VisionCamera: 3ea10c46a5c612f5f89fb46a54bef4a0de8b58a7
- Yoga: 36fee8f1fca3f54a28c3d7f80e69f66d73d3af96
+ RNScreens: 75d04ea58bd7a8e8ac60b7968d0382aa71631a7d
+ RNVectorIcons: 97f26211c07d69e45b189f9dd0dbbe5e2d2b0b92
+ VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f
+ Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b
PODFILE CHECKSUM: 0943ebd85d20bed58095755f929cbce4d6306cad
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/package-lock.json b/package-lock.json
index 4174070..9e9ebc6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,15 +9,20 @@
"version": "0.0.1",
"hasInstallScript": true,
"dependencies": {
+ "@op-engineering/op-sqlite": "^17.1.5",
"@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-stack": "^7.15.1",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-config": "^1.6.1",
+ "react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "^5.5.2",
"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": {
"@babel/core": "^7.25.2",
@@ -33,7 +38,9 @@
"@react-native/typescript-config": "0.85.3",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
+ "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0",
+ "@types/uuid": "^10.0.0",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
@@ -2770,6 +2777,26 @@
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.3.tgz",
@@ -3016,6 +3043,16 @@
"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": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz",
@@ -3638,6 +3675,27 @@
"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": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
@@ -3655,6 +3713,13 @@
"devOptional": true,
"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": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -6377,6 +6442,12 @@
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -9597,7 +9668,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10133,7 +10203,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -10145,7 +10214,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"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": {
"version": "5.8.0",
"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"
}
},
+ "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": {
"version": "4.7.3",
"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_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": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
diff --git a/package.json b/package.json
index b7598af..6860938 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,8 @@
"private": true,
"scripts": {
"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",
"lint": "eslint .",
"postinstall": "node scripts/patch-metro-index.js",
@@ -11,15 +13,20 @@
"test": "jest"
},
"dependencies": {
+ "@op-engineering/op-sqlite": "^17.1.5",
"@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-stack": "^7.15.1",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-config": "^1.6.1",
+ "react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "^5.5.2",
"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": {
"@babel/core": "^7.25.2",
@@ -35,7 +42,9 @@
"@react-native/typescript-config": "0.85.3",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
+ "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0",
+ "@types/uuid": "^10.0.0",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
diff --git a/src/api/attendances.ts b/src/api/attendances.ts
index bc401e7..5099185 100644
--- a/src/api/attendances.ts
+++ b/src/api/attendances.ts
@@ -3,6 +3,8 @@ import type {
AttendanceRecord,
AttendanceSearchParticipant,
AttendanceCheckInResult,
+ OfflineSyncOperation,
+ OfflineSyncResponse,
ParticipantPreview,
} from './types';
@@ -30,4 +32,15 @@ export const attendancesApi = {
{ participantId },
);
},
+
+ syncCheckIns(
+ eventId: string,
+ attendanceId: string,
+ body: { deviceId?: string; operations: OfflineSyncOperation[] },
+ ) {
+ return api.post(
+ `/events/${eventId}/attendances/${attendanceId}/check-ins/sync`,
+ body,
+ );
+ },
};
diff --git a/src/api/types.ts b/src/api/types.ts
index c378b8b..4b408d6 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -74,6 +74,24 @@ export interface AttendanceCheckInResult {
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 {
_id: string;
displayName: string;
diff --git a/src/assets/app-logo.png b/src/assets/app-logo.png
new file mode 100644
index 0000000..604bd3d
Binary files /dev/null and b/src/assets/app-logo.png differ
diff --git a/src/assets/eventbuzz-header-logo.png b/src/assets/eventbuzz-header-logo.png
new file mode 100644
index 0000000..c08b1f5
Binary files /dev/null and b/src/assets/eventbuzz-header-logo.png differ
diff --git a/src/assets/eventbuzz-logo-white.png b/src/assets/eventbuzz-logo-white.png
new file mode 100644
index 0000000..e9f6410
Binary files /dev/null and b/src/assets/eventbuzz-logo-white.png differ
diff --git a/src/components/CheckInResultModal.tsx b/src/components/CheckInResultModal.tsx
index 23a8f8b..cc7a2b9 100644
--- a/src/components/CheckInResultModal.tsx
+++ b/src/components/CheckInResultModal.tsx
@@ -1,47 +1,91 @@
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AttendanceCheckInResult } from '../api/types';
import { colors, radii, spacing, typography } from '../theme';
-import { Card, PrimaryButton } from './ui';
+import { AppIcon, PrimaryButton } from './ui';
type Props = {
visible: boolean;
result: AttendanceCheckInResult | null;
error: string | null;
+ pendingSync?: boolean;
onClose: () => void;
};
-export function CheckInResultModal({ visible, result, error, onClose }: Props) {
- const success = Boolean(result && !error);
+const CHECKIN_GREEN = '#16A34A';
+const PENDING_AMBER = '#D97706';
+
+export function CheckInResultModal({
+ visible,
+ result,
+ error,
+ pendingSync = false,
+ onClose,
+}: Props) {
+ const insets = useSafeAreaInsets();
return (
-
+
- e.stopPropagation()}>
-
+ e.stopPropagation()}
+ >
{error ? (
<>
+
+
+
Check-in failed
{error}
>
) : result ? (
<>
- Checked in
- {result.displayName}
- {result.email ? {result.email} : null}
- {result.phone ? {result.phone} : null}
-
- Session: {result.attendanceName}
-
- {result.lastCheckedInAt ? (
-
- {new Date(result.lastCheckedInAt).toLocaleString()}
-
- ) : null}
+
+
+
+ Checked In!
+
+
+ {result.displayName}
+ {result.email ? (
+
+
+ {result.email}
+
+ ) : null}
+ {result.phone ? (
+
+
+ {result.phone}
+
+ ) : null}
+
+
+ Session: {result.attendanceName}
+
+ {result.lastCheckedInAt ? (
+
+
+
+ {new Date(result.lastCheckedInAt).toLocaleString()}
+
+
+ ) : null}
+ {pendingSync ? (
+
+
+
+ Saved offline — will sync when you are back online
+
+
+ ) : null}
+
>
) : null}
-
-
+
+
@@ -51,37 +95,89 @@ export function CheckInResultModal({ visible, result, error, onClose }: Props) {
const styles = StyleSheet.create({
overlay: {
flex: 1,
- backgroundColor: 'rgba(0,0,0,0.45)',
- justifyContent: 'center',
- padding: 24,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ justifyContent: 'flex-end',
},
- card: {
- borderRadius: radii.xl,
- borderWidth: 0,
+ sheet: {
+ backgroundColor: '#fff',
+ 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: {
- ...typography.h2,
- color: colors.success,
- marginBottom: 12,
+ fontSize: 20,
+ fontWeight: '700',
+ color: CHECKIN_GREEN,
+ textAlign: 'center',
+ marginBottom: spacing.lg,
},
titleError: {
...typography.h2,
color: colors.error,
- marginBottom: 12,
+ textAlign: 'center',
+ marginBottom: spacing.md,
},
name: {
- ...typography.h3,
+ fontSize: 17,
+ fontWeight: '700',
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,
},
+ detailText: {
+ flex: 1,
+ ...typography.small,
+ color: colors.muted,
+ fontSize: 14,
+ },
body: {
...typography.body,
color: colors.text,
- marginBottom: 16,
+ textAlign: 'center',
+ marginBottom: spacing.lg,
},
- meta: {
- ...typography.small,
- color: colors.muted,
- marginBottom: 4,
+ pendingBanner: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ 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,
},
});
diff --git a/src/components/ui/AppIcon.tsx b/src/components/ui/AppIcon.tsx
new file mode 100644
index 0000000..b5076e5
--- /dev/null
+++ b/src/components/ui/AppIcon.tsx
@@ -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;
+};
+
+export function AppIcon({ name, size = 18, color = colors.muted, style }: Props) {
+ return ;
+}
diff --git a/src/components/ui/GradientHeader.tsx b/src/components/ui/GradientHeader.tsx
index f1a9ef5..0a672cd 100644
--- a/src/components/ui/GradientHeader.tsx
+++ b/src/components/ui/GradientHeader.tsx
@@ -1,24 +1,33 @@
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing, typography } from '../../theme';
type Props = {
title?: string;
subtitle?: string;
+ onBack?: () => void;
right?: React.ReactNode;
bottom?: React.ReactNode;
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 (
-
- {/* Faux “gradient”: solid orange with decorative circles. Replace with LinearGradient once deps install. */}
+
+ {onBack ? (
+
+ ‹ Back
+
+ ) : null}
+
{title ? {title} : null}
@@ -34,14 +43,14 @@ export function GradientHeader({ title, subtitle, right, bottom, style }: Props)
const styles = StyleSheet.create({
wrap: {
- paddingTop: 52,
paddingBottom: 18,
paddingHorizontal: spacing.xl,
overflow: 'hidden',
},
bg: {
- ...StyleSheet.absoluteFillObject,
+ ...StyleSheet.absoluteFill,
backgroundColor: colors.primary,
+ zIndex: 0,
},
circleA: {
position: 'absolute',
@@ -51,6 +60,7 @@ const styles = StyleSheet.create({
right: -40,
top: -40,
backgroundColor: 'rgba(255,255,255,0.12)',
+ zIndex: 0,
},
circleB: {
position: 'absolute',
@@ -60,12 +70,24 @@ const styles = StyleSheet.create({
left: -24,
bottom: -32,
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: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
+ zIndex: 1,
},
textCol: { flex: 1 },
title: {
@@ -77,7 +99,6 @@ const styles = StyleSheet.create({
...typography.small,
color: 'rgba(255,255,255,0.75)',
},
- right: { alignItems: 'flex-end' },
- bottom: { marginTop: 14 },
+ right: { alignItems: 'flex-end', zIndex: 1 },
+ bottom: { marginTop: 14, zIndex: 1 },
});
-
diff --git a/src/components/ui/PrimaryButton.tsx b/src/components/ui/PrimaryButton.tsx
index 7a38732..c9d1b30 100644
--- a/src/components/ui/PrimaryButton.tsx
+++ b/src/components/ui/PrimaryButton.tsx
@@ -23,9 +23,7 @@ export function PrimaryButton({ title, loading, disabled, style, ...props }: Pro
style,
]}
>
- {/* Faux “gradient”: solid base + darker highlight strip. Replace with LinearGradient once deps install. */}
-
{loading ? : {title}}
@@ -42,18 +40,9 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
bg: {
- ...StyleSheet.absoluteFillObject,
+ ...StyleSheet.absoluteFill,
backgroundColor: colors.primary,
},
- highlight: {
- position: 'absolute',
- left: 0,
- right: 0,
- top: 0,
- height: '55%',
- backgroundColor: colors.primaryDark,
- opacity: 0.35,
- },
content: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/src/components/ui/TextField.tsx b/src/components/ui/TextField.tsx
index 09ce916..f3a4949 100644
--- a/src/components/ui/TextField.tsx
+++ b/src/components/ui/TextField.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import type { TextInputProps, ViewStyle } from 'react-native';
import { colors, radii, typography } from '../../theme';
@@ -10,22 +10,36 @@ type Props = TextInputProps & {
containerStyle?: ViewStyle | ViewStyle[];
};
-export function TextField({ label, left, right, containerStyle, style, ...props }: Props) {
+export const TextField = React.forwardRef(function TextField(
+ { label, left, right, containerStyle, style, onFocus, onBlur, ...props },
+ ref,
+) {
+ const [focused, setFocused] = useState(false);
+
return (
- {label ? {label} : null}
-
+ {label ? {label} : null}
+
{left ? {left} : null}
{
+ setFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setFocused(false);
+ onBlur?.(e);
+ }}
+ style={[styles.input, focused && styles.inputFocused, style]}
placeholderTextColor={colors.muted}
/>
{right ? {right} : null}
);
-}
+});
const styles = StyleSheet.create({
label: {
@@ -33,6 +47,9 @@ const styles = StyleSheet.create({
color: colors.muted,
marginBottom: 8,
},
+ labelFocused: {
+ color: colors.primaryDark,
+ },
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -40,10 +57,14 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: radii.md,
- borderWidth: 1,
+ borderWidth: 2,
borderColor: colors.border,
backgroundColor: '#F9FAFB',
},
+ rowFocused: {
+ borderColor: colors.primary,
+ backgroundColor: '#FFFFFF',
+ },
side: { alignItems: 'center', justifyContent: 'center' },
input: {
flex: 1,
@@ -51,5 +72,7 @@ const styles = StyleSheet.create({
color: colors.text,
paddingVertical: 0,
},
+ inputFocused: {
+ color: colors.text,
+ },
});
-
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 9bc3d80..32db55e 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,3 +1,4 @@
+export * from './AppIcon';
export * from './Card';
export * from './PrimaryButton';
export * from './TextField';
diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx
index 97f0e9e..c36bed4 100644
--- a/src/navigation/RootNavigator.tsx
+++ b/src/navigation/RootNavigator.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
-import { ActivityIndicator, View } from 'react-native';
-import { NavigationContainer } from '@react-navigation/native';
+import React, { useEffect } from 'react';
+import { ActivityIndicator, Platform, StatusBar, View } from 'react-native';
+import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../context/AuthContext';
import { LoginScreen } from '../screens/LoginScreen';
@@ -14,15 +14,60 @@ import { colors } from '../theme';
import { getAccessToken } from '../api/client';
const Stack = createNativeStackNavigator();
+const navigationRef = createNavigationContainerRef();
+
+/** Android only — iOS uses 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() {
const { loading, user } = useAuth();
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) {
return (
-
-
+
+
+
);
}
@@ -30,44 +75,31 @@ export function RootNavigator() {
const initialRoute = user || hasToken ? 'Events' : 'Login';
return (
-
-
+
+
-
-
-
-
+
+
+
+
diff --git a/src/offline/OfflineContext.tsx b/src/offline/OfflineContext.tsx
new file mode 100644
index 0000000..a525955
--- /dev/null
+++ b/src/offline/OfflineContext.tsx
@@ -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;
+ refreshStatuses: () => Promise;
+ syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>;
+};
+
+const OfflineContext = createContext(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(null);
+ const [statusByEvent, setStatusByEvent] = useState>({});
+
+ const refreshStatuses = useCallback(async () => {
+ const ids = await listOfflineEventIds();
+ const next: Record = {};
+ 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 {children};
+}
+
+export function useOffline(): OfflineContextValue {
+ const ctx = useContext(OfflineContext);
+ if (!ctx) {
+ throw new Error('useOffline must be used within OfflineProvider');
+ }
+ return ctx;
+}
diff --git a/src/offline/checkInService.ts b/src/offline/checkInService.ts
new file mode 100644
index 0000000..2eb8dd0
--- /dev/null
+++ b/src/offline/checkInService.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/src/offline/db.ts b/src/offline/db.ts
new file mode 100644
index 0000000..32791a2
--- /dev/null
+++ b/src/offline/db.ts
@@ -0,0 +1,88 @@
+import { open, type DB } from '@op-engineering/op-sqlite';
+
+let db: DB | null = null;
+let readyPromise: Promise | 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 {
+ 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;
+}
diff --git a/src/offline/deviceId.ts b/src/offline/deviceId.ts
new file mode 100644
index 0000000..9c6cb7d
--- /dev/null
+++ b/src/offline/deviceId.ts
@@ -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 {
+ 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;
+}
diff --git a/src/offline/download.ts b/src/offline/download.ts
new file mode 100644
index 0000000..46027b3
--- /dev/null
+++ b/src/offline/download.ts
@@ -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,
+ 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,
+ 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 };
+}
diff --git a/src/offline/repos.ts b/src/offline/repos.ts
new file mode 100644
index 0000000..955d711
--- /dev/null
+++ b/src/offline/repos.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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): 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 {
+ 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);
+ 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 {
+ 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);
+}
+
+export async function applyLocalCheckIn(
+ attendanceId: string,
+ participantId: string,
+ checkedInAt: string,
+): Promise {
+ 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 {
+ 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 {
+ 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): 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 {
+ 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));
+}
+
+export async function listConflictOutbox(eventId: string): Promise {
+ 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));
+}
+
+export async function countOutboxByStatus(
+ eventId: string,
+ statuses: OutboxStatus[],
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 {
+ const event = await getOfflineEvent(eventId);
+ return Boolean(event);
+}
diff --git a/src/offline/syncEngine.ts b/src/offline/syncEngine.ts
new file mode 100644
index 0000000..1831ed1
--- /dev/null
+++ b/src/offline/syncEngine.ts
@@ -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();
+
+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 {
+ 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();
+ 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();
+
+ 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);
+ }
+}
diff --git a/src/offline/types.ts b/src/offline/types.ts
new file mode 100644
index 0000000..eb94604
--- /dev/null
+++ b/src/offline/types.ts
@@ -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;
+};
diff --git a/src/screens/AttendanceSessionsScreen.tsx b/src/screens/AttendanceSessionsScreen.tsx
index 95ce6ed..ea082a9 100644
--- a/src/screens/AttendanceSessionsScreen.tsx
+++ b/src/screens/AttendanceSessionsScreen.tsx
@@ -1,125 +1,448 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
+ Alert,
FlatList,
+ Platform,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { attendancesApi } from '../api/attendances';
import type { AttendanceRecord } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
-import { colors, radii, spacing, typography } from '../theme';
-import { Badge, Card, GradientHeader } from '../components/ui';
+import { useOffline } from '../offline/OfflineContext';
+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;
+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 (
+
+
+
+
+
+
+
+ Events
+
+ {offlineReady ? (
+
+ {syncing ? (
+
+ ) : (
+
+ )}
+
+ Sync{pendingCount > 0 ? ` (${pendingCount})` : ''}
+
+
+ ) : null}
+
+
+
+ {eventName}
+
+
+ {offlineReady
+ ? isOnline
+ ? 'Offline pack ready · Select a session'
+ : 'Working offline · Select a session'
+ : 'Select an attendance session'}
+
+
+ );
+}
+
+function SessionsListHeader({ count }: { count: number }) {
+ return (
+
+ {count} session{count === 1 ? '' : 's'} available
+
+ );
+}
+
export function AttendanceSessionsScreen({ navigation, route }: Props) {
const { eventId, eventName } = route.params;
+ const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [fromCache, setFromCache] = useState(false);
+
+ const status = statusByEvent[eventId];
+ const offlineReady = Boolean(status?.enabled);
const load = useCallback(async () => {
setError(null);
- const res = await attendancesApi.list(eventId);
- if (res.error) {
- setError(res.error);
- setSessions([]);
- } else {
- const active = (res.data?.attendances ?? []).filter((s) => s.status === 'active');
- setSessions(active);
+
+ if (isOnline) {
+ const res = await attendancesApi.list(eventId);
+ if (!res.error && res.data) {
+ const active = (res.data.attendances ?? []).filter((s) => s.status === '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(() => {
(async () => {
setLoading(true);
await load();
+ await refreshStatuses();
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 (
+
+ navigation.navigate('Participants', {
+ eventId,
+ eventName,
+ attendanceId: item._id,
+ attendanceName: item.name,
+ allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
+ })
+ }
+ >
+
+
+
+
+
+
+
+ {item.name}
+
+
+
+
+ {checkIns} check-in{checkIns === 1 ? '' : 's'}
+
+
+ ·
+
+
+
+ {people} {people === 1 ? 'person' : 'people'}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
return (
- navigation.goBack()}
+ onSync={() => { handleSync(); }}
+ syncing={syncing}
+ pendingCount={status?.pendingCount ?? 0}
+ isOnline={isOnline}
+ offlineReady={offlineReady}
/>
+ {fromCache ? (
+
+
+ Showing offline data
+
+ ) : null}
+
{loading ? (
) : (
item._id}
+ renderItem={renderItem}
+ contentInsetAdjustmentBehavior="never"
+ ListHeaderComponent={}
contentContainerStyle={sessions.length === 0 ? styles.emptyList : styles.list}
ListEmptyComponent={
{error || 'No active attendance sessions.'}
}
- renderItem={({ item }) => (
-
- navigation.navigate('Participants', {
- eventId,
- eventName,
- attendanceId: item._id,
- attendanceName: item.name,
- allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
- })
- }
- >
-
-
-
- {item.name}
-
- {item.totalCheckedIn ?? 0} check-in
- {(item.totalCheckedIn ?? 0) === 1 ? '' : 's'} ·{' '}
- {item.uniqueParticipants ?? 0} people
-
-
- {item.uniqueParticipants ?? 0} attendees
-
-
-
- ›
-
-
-
-
- )}
/>
)}
);
}
+const cardShadow = Platform.OS === 'ios' ? shadows.card.ios : shadows.card.android;
+
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background },
- header: { paddingBottom: 26 },
- list: { padding: spacing.lg, paddingTop: spacing.lg },
- emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 },
+ header: {
+ paddingHorizontal: spacing.xl,
+ 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 },
cardPress: { marginBottom: 12 },
- card: { borderRadius: radii.xl, borderWidth: 0 },
- row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
- rowText: { flex: 1 },
- cardTitle: { color: colors.text, fontSize: 15, fontWeight: '800', marginBottom: 6 },
- cardMeta: { ...typography.small, color: colors.muted },
- badges: { marginTop: 10 },
- chev: {
- width: 34,
- height: 34,
- borderRadius: 17,
+ card: {
+ borderRadius: radii.xl,
+ borderWidth: 0,
+ ...cardShadow,
+ },
+ cardRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 14,
+ },
+ iconBox: {
+ width: 48,
+ height: 48,
+ borderRadius: 14,
alignItems: '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 },
});
diff --git a/src/screens/EventsScreen.tsx b/src/screens/EventsScreen.tsx
index 47b19f8..a31eff7 100644
--- a/src/screens/EventsScreen.tsx
+++ b/src/screens/EventsScreen.tsx
@@ -1,20 +1,27 @@
import React, { useCallback, useState } from 'react';
import {
ActivityIndicator,
+ Alert,
FlatList,
+ Image,
+ Platform,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { checkinsApi } from '../api/checkins';
import type { CheckinsEvent } from '../api/types';
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 { colors, radii, spacing, typography } from '../theme';
-import { AvatarInitials, Badge, Card, GradientHeader } from '../components/ui';
+import { colors, radii, shadows, spacing, typography } from '../theme';
+import { AppIcon, AvatarInitials, Card } from '../components/ui';
type Props = NativeStackScreenProps;
@@ -25,23 +32,141 @@ function formatDate(iso?: string) {
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 (
+
+
+
+
+
+
+
+
+
+ {syncing ? (
+
+ ) : (
+
+ )}
+
+ Sync{pendingTotal > 0 ? ` (${pendingTotal})` : ''}
+
+
+
+
+ Logout
+
+
+
+
+
+
+
+ Signed in as
+
+ {userEmail || '—'}
+
+
+
+ {isOnline ? 'Online' : 'Offline'}
+
+
+
+ );
+}
+
+function EventsListHeader({ count }: { count: number }) {
+ return (
+
+ Your Events
+
+ {count} event{count === 1 ? '' : 's'}
+
+
+ );
+}
+
export function EventsScreen({ navigation }: Props) {
const { signOut, user } = useAuth();
+ const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
+ const [busyEventId, setBusyEventId] = useState(null);
const load = useCallback(async () => {
setError(null);
- const res = await checkinsApi.listEvents();
- if (res.error) {
- setError(res.error);
- setEvents([]);
- } else {
- setEvents(res.data?.events ?? []);
+ if (isOnline) {
+ const res = await checkinsApi.listEvents();
+ if (!res.error && res.data) {
+ setEvents(res.data.events ?? []);
+ await refreshStatuses();
+ 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(() => {
(async () => {
@@ -57,65 +182,214 @@ export function EventsScreen({ navigation }: Props) {
setRefreshing(false);
};
- const renderItem = ({ item }: { item: CheckinsEvent }) => (
-
- navigation.navigate('AttendanceSessions', {
- eventId: item._id,
- eventName: item.name,
- })
- }
- >
-
-
-
- {item.name}
-
-
- {formatDate(item.startDate)}
- {item.endDate && item.endDate !== item.startDate
- ? ` – ${formatDate(item.endDate)}`
- : ''}
-
+ const handleLogout = async () => {
+ await signOut();
+ navigation.reset({
+ index: 0,
+ routes: [{ name: 'Login' }],
+ });
+ };
+
+ const handleSync = async () => {
+ const result = await syncNow();
+ if (!result.ok && result.error) {
+ Alert.alert('Sync', result.error);
+ } else if (result.flushed > 0) {
+ Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
+ } else {
+ Alert.alert('Sync', 'Nothing pending to sync.');
+ }
+ };
+
+ const enableOffline = async (item: CheckinsEvent) => {
+ 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 (
+
+ navigation.navigate('AttendanceSessions', {
+ eventId: item._id,
+ eventName: item.name,
+ })
+ }
+ onLongPress={() => openOfflineMenu(item)}
+ >
+
+
+
+ {item.name}
+
+
+
+ {formatDateRange(item.startDate, item.endDate)}
+
+
+ {item.location ? (
+
+
+
+ {item.location}
+
+
+ ) : null}
+
+
+
+
+ {sessionLabel}
+
+ {status?.enabled ? (
+ 0 ? styles.offlineBadgePending : null,
+ ]}
+ >
+ 0 ? 'cloud-upload-outline' : 'cloud-done-outline'}
+ size={12}
+ color={status.pendingCount > 0 ? '#92400E' : '#166534'}
+ />
+ 0 ? styles.offlineBadgeTextPending : null,
+ ]}
+ >
+ {status.pendingCount > 0
+ ? `${status.pendingCount} pending`
+ : 'Offline ready'}
+
+
+ ) : null}
+
- {item.location ? {item.location} : null}
-
-
- {item.attendanceSessionCount} session
- {item.attendanceSessionCount === 1 ? '' : 's'}
-
+
+
+ openOfflineMenu(item)}
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+
+
-
- ›
-
-
-
-
- );
+
+
+ );
+ };
return (
-
- Logout
-
- }
- bottom={
-
-
-
- Your Events
-
- {events.length} event{events.length === 1 ? '' : 's'}
-
-
-
- }
+ { handleSync(); }}
+ syncing={syncing}
+ pendingTotal={pendingTotal}
+ isOnline={isOnline}
/>
{loading ? (
@@ -125,14 +399,12 @@ export function EventsScreen({ navigation }: Props) {
data={events}
keyExtractor={(item) => item._id}
renderItem={renderItem}
+ contentInsetAdjustmentBehavior="never"
+ ListHeaderComponent={}
contentContainerStyle={events.length === 0 ? styles.emptyList : styles.list}
- refreshControl={
-
- }
+ refreshControl={}
ListEmptyComponent={
-
- {error || 'No events with attendance sessions.'}
-
+ {error || 'No events assigned to your account.'}
}
/>
)}
@@ -140,38 +412,209 @@ export function EventsScreen({ navigation }: Props) {
);
}
+const cardShadow = Platform.OS === 'ios' ? shadows.card.ios : shadows.card.android;
+
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background },
- logout: { color: 'rgba(255,255,255,0.85)', ...typography.small, fontWeight: '600' },
- headerBottom: {
- marginTop: 16,
+ header: {
+ paddingHorizontal: spacing.xl,
+ 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',
alignItems: 'center',
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 },
cardPress: { marginBottom: 12 },
- card: { borderRadius: radii.xl, borderWidth: 0 },
+ card: {
+ borderRadius: radii.xl,
+ borderWidth: 0,
+ ...cardShadow,
+ },
cardRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
cardText: { flex: 1, minWidth: 0 },
- cardTitle: { color: colors.text, fontSize: 15, fontWeight: '800', marginBottom: 8 },
- metaRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
- cardMeta: { color: colors.muted, fontSize: 12, marginBottom: 4 },
- badges: { marginTop: 10 },
- chev: {
- width: 34,
- height: 34,
- borderRadius: 17,
+ cardTitle: {
+ color: colors.text,
+ fontSize: 15,
+ fontWeight: '700',
+ marginBottom: 10,
+ },
+ metaRow: {
+ 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',
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 },
});
diff --git a/src/screens/Login2FAScreen.tsx b/src/screens/Login2FAScreen.tsx
index ed91220..d04ff22 100644
--- a/src/screens/Login2FAScreen.tsx
+++ b/src/screens/Login2FAScreen.tsx
@@ -1,9 +1,8 @@
import React, { useState } from 'react';
import {
- ActivityIndicator,
- KeyboardAvoidingView,
Platform,
Pressable,
+ ScrollView,
StyleSheet,
Text,
View,
@@ -42,17 +41,21 @@ export function Login2FAScreen({ navigation, route }: Props) {
};
return (
-
-
-
+
+
+
{error ? (
@@ -79,21 +82,21 @@ export function Login2FAScreen({ navigation, route }: Props) {
-
-
+
+
);
}
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background },
- inner: { flex: 1 },
hero: {
paddingTop: 72,
paddingBottom: 44,
paddingHorizontal: spacing.xl,
},
+ scroll: { flex: 1 },
+ scrollContent: { flexGrow: 1 },
sheet: {
- flex: 1,
marginTop: -28,
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx
index d872e44..f9317ce 100644
--- a/src/screens/LoginScreen.tsx
+++ b/src/screens/LoginScreen.tsx
@@ -1,14 +1,13 @@
-import React, { useState } from 'react';
+import React, { useRef, useState } from 'react';
import {
Image,
- KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StatusBar,
StyleSheet,
Text,
- useWindowDimensions,
+ TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -17,19 +16,21 @@ import { authApi } from '../api/auth';
import type { RootStackParamList } from '../navigation/types';
import { useAuth } from '../context/AuthContext';
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;
const BRAND_ORANGE = '#F7941D';
const BRAND_ORANGE_DARK = '#E8780A';
-const BUTTON_HEIGHT = 52;
-const BOTTOM_BAR_PAD_Y = 16;
+/** Fixed height — do not tie to window size (keyboard changes dimensions and causes flicker). */
+const HERO_BODY_HEIGHT = 240;
+
export function LoginScreen({ navigation }: Props) {
const { refreshUser } = useAuth();
- const { height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
- const heroHeight = Math.round(windowHeight * 0.42);
+ const passwordRef = useRef(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -74,12 +75,15 @@ export function LoginScreen({ navigation }: Props) {
- {/* Orange hero — fixed pixel height so it always renders */}
-
+
-
Attendance Management
-
-
-
- Welcome back
- Sign in to your dashboard account
+ Welcome back
+ Sign in to your dashboard account
- {error ? (
-
- {error}
-
- ) : null}
-
-
- ✉}
- />
-
- 🔒}
- right={
- setShowPw((v) => !v)} hitSlop={10}>
- {showPw ? '🙈' : '👁'}
-
- }
- />
-
-
-
-
-
+ {error ? (
+
+ {error}
+ ) : null}
+
+
+ passwordRef.current?.focus()}
+ placeholder="you@example.com"
+ left={}
+ />
+
+ }
+ right={
+ setShowPw((v) => !v)} hitSlop={10}>
+
+
+ }
+ />
-
+
+
+
);
}
@@ -169,7 +172,7 @@ const styles = StyleSheet.create({
backgroundColor: BRAND_ORANGE,
alignItems: 'center',
justifyContent: 'flex-end',
- paddingBottom: 36,
+ paddingBottom: 28,
overflow: 'hidden',
},
heroGradientDark: {
@@ -201,7 +204,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.10)',
},
logoCard: {
- backgroundColor: '#fff',
+ backgroundColor: 'rgba(255,255,255,0.92)',
borderRadius: 16,
paddingHorizontal: 20,
paddingVertical: 16,
@@ -213,38 +216,30 @@ const styles = StyleSheet.create({
shadowRadius: 20,
elevation: 8,
},
- logo: { width: 144, height: 52 },
+ logo: { width: 140, height: 73 },
heroSubtitle: {
- color: 'rgba(255,255,255,0.85)',
+ color: 'rgba(255,255,255,0.92)',
fontSize: 14,
+ fontWeight: '500',
letterSpacing: 0.4,
zIndex: 2,
+ marginBottom: 10
},
formArea: {
flex: 1,
- },
- sheet: {
- flex: 1,
- marginTop: -28,
+ marginTop: -24,
backgroundColor: '#fff',
borderTopLeftRadius: 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,
- paddingTop: 32,
- paddingBottom: 16,
+ paddingTop: 28,
},
title: { ...typography.title, color: colors.text, marginBottom: 6 },
sub: { ...typography.body, color: colors.muted, marginBottom: 24 },
fields: { gap: 16 },
- fieldIcon: { fontSize: 16, color: '#9CA3AF' },
errorBox: {
backgroundColor: 'rgba(220,38,38,0.08)',
borderWidth: 1,
@@ -256,20 +251,6 @@ const styles = StyleSheet.create({
},
errorText: { ...typography.body, color: colors.error },
signInBtn: {
- borderRadius: 14,
- 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',
+ marginTop: spacing.lg,
},
});
diff --git a/src/screens/ParticipantsScreen.tsx b/src/screens/ParticipantsScreen.tsx
index 34dbd76..a3a3126 100644
--- a/src/screens/ParticipantsScreen.tsx
+++ b/src/screens/ParticipantsScreen.tsx
@@ -5,18 +5,31 @@ import {
Pressable,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal';
import 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 { Card, GradientHeader, PrimaryButton, TextField } from '../components/ui';
+import { AppIcon, Card } from '../components/ui';
type Props = NativeStackScreenProps;
+const CHECKIN_GREEN = '#22C55E';
+const AGAIN_BLUE = '#2563EB';
+const NAVY_SCAN = '#1A1A2E';
+
function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean {
const needle = q.trim().toLowerCase();
if (!needle) return true;
@@ -27,8 +40,96 @@ function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean
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 (
+
+
+
+
+
+
+
+ {eventName}
+
+
+
+
+
+
+ {sessionName}
+
+
+ Attendance · {checkedInCount}/{totalCount} checked in
+
+
+
+ {checkedInCount}
+ checked in
+
+
+
+ );
+}
+
+function SearchBar({
+ value,
+ onChangeText,
+}: {
+ value: string;
+ onChangeText: (text: string) => void;
+}) {
+ return (
+
+
+
+
+ {value.length > 0 ? (
+ onChangeText('')} hitSlop={8}>
+
+
+ ) : null}
+
+
+ );
+}
+
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([]);
const [searchQ, setSearchQ] = useState('');
const [loading, setLoading] = useState(true);
@@ -36,18 +137,46 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
const [resultError, setResultError] = useState(null);
+ const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
+ const [fromCache, setFromCache] = useState(false);
const load = useCallback(async () => {
setError(null);
- const res = await attendancesApi.searchParticipants(eventId, attendanceId);
- if (res.error) {
- setError(res.error);
- setAll([]);
- } else {
- setAll(res.data?.participants ?? []);
+ const packActive = await isOfflinePackActive(eventId);
+ const pending = packActive ? await countPendingForSession(attendanceId) : 0;
+
+ // Prefer local roster when offline, or when unsynced outbox would diverge from server.
+ if (packActive && (!isOnline || pending > 0)) {
+ 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(() => {
const unsub = navigation.addListener('focus', () => {
@@ -66,111 +195,181 @@ export function ParticipantsScreen({ navigation, route }: Props) {
[all, searchQ],
);
+ const checkedInCount = useMemo(
+ () => all.filter((p) => p.alreadyCheckedIn).length,
+ [all],
+ );
+
const checkIn = async (participantId: string) => {
setBusyId(participantId);
setResultError(null);
- const res = await attendancesApi.checkIn(eventId, attendanceId, participantId);
+ setPendingSync(false);
+ const outcome = await performAppCheckIn({
+ eventId,
+ attendanceId,
+ attendanceName,
+ participantId,
+ });
setBusyId(null);
- if (res.error) {
+ if (!outcome.ok) {
setResult(null);
- setResultError(res.error);
+ setResultError(outcome.error);
setModalVisible(true);
return;
}
- setResult(res.data ?? null);
+ setResult(outcome.result);
+ setPendingSync(outcome.pendingSync);
setResultError(null);
setModalVisible(true);
await load();
+ await refreshStatuses();
};
const closeModal = () => {
setModalVisible(false);
setResult(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 (
+
+
+
+
+ {getInitial(item.displayName)}
+
+
+
+
+
+ {item.displayName}
+
+
+ {meta}
+
+ {item.alreadyCheckedIn ? (
+
+
+
+ Checked in{checkInTime ? ` · ${checkInTime}` : ''}
+
+
+ ) : null}
+
+
+ {showAction ? (
+ checkIn(item._id)}
+ >
+ {busyId === item._id ? (
+
+ ) : isAgain ? (
+
+
+ Again
+
+ ) : (
+ Check In
+ )}
+
+ ) : null}
+
+
+ );
};
return (
-
- }
+ navigation.goBack()}
/>
+
+
+ {fromCache ? (
+
+
+ Offline roster
+
+ ) : null}
+
+
+
+ {displayed.length} participant{displayed.length === 1 ? '' : 's'}
+
+
+
{loading ? (
) : (
item._id}
- contentContainerStyle={displayed.length === 0 ? styles.emptyList : styles.list}
- ListEmptyComponent={
- {error || 'No participants found.'}
+ renderItem={renderItem}
+ contentInsetAdjustmentBehavior="never"
+ contentContainerStyle={
+ displayed.length === 0
+ ? [styles.emptyList, { paddingBottom: listBottomPad }]
+ : [styles.list, { paddingBottom: listBottomPad }]
+ }
+ ListEmptyComponent={
+
+
+ {error || 'No participants found'}
+
}
- renderItem={({ item }) => {
- const canCheckIn = !item.alreadyCheckedIn || allowDuplicate;
- return (
-
-
-
- {item.displayName}
-
- {[item.email, item.phone].filter(Boolean).join(' · ') || '—'}
-
- {item.alreadyCheckedIn ? (
- Already checked in
- ) : null}
-
- checkIn(item._id)}
- >
- {busyId === item._id ? (
-
- ) : (
-
- {item.alreadyCheckedIn && allowDuplicate ? 'Again' : 'Check in'}
-
- )}
-
-
-
- );
- }}
/>
)}
-
-
- navigation.navigate('QrScanner', {
- eventId,
- attendanceId,
- attendanceName,
- allowDuplicate,
- })
- }
- />
-
+
+ navigation.navigate('QrScanner', {
+ eventId,
+ attendanceId,
+ attendanceName,
+ allowDuplicate,
+ })
+ }
+ >
+
+ Scan QR
+
@@ -179,32 +378,234 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.background },
- header: { paddingBottom: 18 },
- searchContainer: { marginTop: 14 },
- list: { paddingHorizontal: spacing.lg, paddingBottom: 120, paddingTop: spacing.lg },
- emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 },
- 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',
+ header: {
+ paddingHorizontal: spacing.xl,
+ paddingBottom: spacing.lg,
+ overflow: 'hidden',
},
- checkBtnOff: { backgroundColor: colors.disabled },
- checkBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
- empty: { textAlign: 'center', color: colors.muted },
- bottomBar: {
+ 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,
+ },
+ 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',
- left: 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',
},
});
diff --git a/src/screens/QrScannerScreen.tsx b/src/screens/QrScannerScreen.tsx
index a6be0e1..f70b66c 100644
--- a/src/screens/QrScannerScreen.tsx
+++ b/src/screens/QrScannerScreen.tsx
@@ -1,5 +1,13 @@
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 {
Camera,
@@ -7,24 +15,112 @@ import {
useCameraPermission,
useCodeScanner,
} from 'react-native-vision-camera';
-import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal';
import type { RootStackParamList } from '../navigation/types';
+import { performAppCheckIn } from '../offline/checkInService';
+import { useOffline } from '../offline/OfflineContext';
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
import { colors, radii, spacing, typography } from '../theme';
import { PrimaryButton } from '../components/ui';
type Props = NativeStackScreenProps;
+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 ;
+}
+
+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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
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 { refreshStatuses } = useOffline();
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const scannedRef = useRef(false);
const [processing, setProcessing] = useState(false);
const [result, setResult] = useState(null);
const [resultError, setResultError] = useState(null);
+ const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const handleScan = useCallback(
@@ -35,6 +131,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
scannedRef.current = true;
setResult(null);
setResultError(parsed.error);
+ setPendingSync(false);
setModalVisible(true);
return;
}
@@ -42,20 +139,29 @@ export function QrScannerScreen({ navigation, route }: Props) {
scannedRef.current = true;
setProcessing(true);
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);
- if (res.error) {
+ if (!outcome.ok) {
setResult(null);
- setResultError(res.error);
+ setResultError(outcome.error);
+ setPendingSync(false);
} else {
- setResult(res.data ?? null);
+ setResult(outcome.result);
setResultError(null);
+ setPendingSync(outcome.pendingSync);
+ refreshStatuses().catch(() => undefined);
}
setModalVisible(true);
},
- [eventId, attendanceId, processing],
+ [eventId, attendanceId, attendanceName, processing, refreshStatuses],
);
const codeScanner = useCodeScanner({
@@ -70,6 +176,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
setModalVisible(false);
setResult(null);
setResultError(null);
+ setPendingSync(false);
scannedRef.current = false;
if (result && !resultError) {
navigation.goBack();
@@ -102,15 +209,30 @@ export function QrScannerScreen({ navigation, route }: Props) {
}
return (
-
+ setLayoutHeight(e.nativeEvent.layout.height)}
+ >
-
-
+
+
+ {
+ const { y, height } = e.nativeEvent.layout;
+ setHeaderBottom(headerTop + y + height);
+ }}
+ >
{attendanceName}
Point at participant badge QR
{processing ? (
@@ -118,7 +240,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
) : null}
-
+
navigation.goBack()}>
Cancel
@@ -128,6 +250,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
visible={modalVisible}
result={result}
error={resultError}
+ pendingSync={pendingSync}
onClose={closeModal}
/>
@@ -136,12 +259,62 @@ export function QrScannerScreen({ navigation, route }: Props) {
const styles = StyleSheet.create({
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: {
position: 'absolute',
- top: 48,
left: 0,
right: 0,
alignItems: 'center',
+ zIndex: 2,
},
topPill: {
width: '92%',
@@ -160,7 +333,7 @@ const styles = StyleSheet.create({
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
- bottom: 22,
+ zIndex: 2,
},
cancel: {
paddingHorizontal: 24,