Improve check-in UX and offline support.
Update attendance/QR flows, branding assets, and related React Native screens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
153d552894
commit
38bfd5843c
12
App.tsx
12
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 { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import Ionicons from 'react-native-vector-icons/Ionicons';
|
||||||
import { AuthProvider } from './src/context/AuthContext';
|
import { AuthProvider } from './src/context/AuthContext';
|
||||||
|
import { OfflineProvider } from './src/offline/OfflineContext';
|
||||||
import { RootNavigator } from './src/navigation/RootNavigator';
|
import { RootNavigator } from './src/navigation/RootNavigator';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void Ionicons.loadFont();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
|
{Platform.OS === 'ios' ? <StatusBar barStyle="light-content" /> : null}
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<OfflineProvider>
|
||||||
<RootNavigator />
|
<RootNavigator />
|
||||||
|
</OfflineProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
10
README.md
10
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.
|
If Gradle reports `Could not find org.asyncstorage.shared_storage:storage-android:1.0.0`, the project’s `android/build.gradle` already adds the package’s `local_repo` Maven path. After `npm install`, run `npm run android` again.
|
||||||
|
|
||||||
|
## Offline mode
|
||||||
|
|
||||||
|
1. While online, open **Events** and tap the cloud icon (or long-press an event) → **Enable Offline**.
|
||||||
|
2. The app downloads active attendance sessions + participant rosters into local SQLite.
|
||||||
|
3. Check in via search or QR without internet; items queue in an outbox.
|
||||||
|
4. Tap **Sync** (or wait for reconnect) to flush via `POST .../check-ins/sync`.
|
||||||
|
5. **Refresh Offline Data** updates the snapshot without clearing pending outbox rows.
|
||||||
|
|
||||||
|
Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and set `API_BASE_URL`:
|
Copy `.env.example` to `.env` and set `API_BASE_URL`:
|
||||||
|
|
|
||||||
2
app.json
2
app.json
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"name": "EventBuzzCheckins",
|
"name": "EventBuzzCheckins",
|
||||||
"displayName": "EventBuzzCheckins"
|
"displayName": "EventBuzz Checkins"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
index.js
1
index.js
|
|
@ -2,6 +2,7 @@
|
||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import 'react-native-get-random-values';
|
||||||
import { AppRegistry } from 'react-native';
|
import { AppRegistry } from 'react-native';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { name as appName } from './app.json';
|
import { name as appName } from './app.json';
|
||||||
|
|
|
||||||
236
ios/Podfile.lock
236
ios/Podfile.lock
|
|
@ -25,6 +25,28 @@ PODS:
|
||||||
- hermes-engine (250829098.0.10):
|
- hermes-engine (250829098.0.10):
|
||||||
- hermes-engine/Pre-built (= 250829098.0.10)
|
- hermes-engine/Pre-built (= 250829098.0.10)
|
||||||
- hermes-engine/Pre-built (250829098.0.10)
|
- hermes-engine/Pre-built (250829098.0.10)
|
||||||
|
- op-sqlite (17.1.5):
|
||||||
|
- hermes-engine
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-Core-prebuilt
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- ReactNativeDependencies
|
||||||
|
- Yoga
|
||||||
- RCTDeprecation (0.85.3)
|
- RCTDeprecation (0.85.3)
|
||||||
- RCTRequired (0.85.3)
|
- RCTRequired (0.85.3)
|
||||||
- RCTSwiftUI (0.85.3)
|
- RCTSwiftUI (0.85.3)
|
||||||
|
|
@ -1448,6 +1470,50 @@ PODS:
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- react-native-get-random-values (2.0.0):
|
||||||
|
- hermes-engine
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-Core-prebuilt
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- ReactNativeDependencies
|
||||||
|
- Yoga
|
||||||
|
- react-native-netinfo (12.0.1):
|
||||||
|
- hermes-engine
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-Core-prebuilt
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- ReactNativeDependencies
|
||||||
|
- Yoga
|
||||||
- react-native-safe-area-context (5.8.0):
|
- react-native-safe-area-context (5.8.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
|
|
@ -1949,6 +2015,28 @@ PODS:
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- RNVectorIcons (10.3.0):
|
||||||
|
- hermes-engine
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-Core-prebuilt
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- ReactNativeDependencies
|
||||||
|
- Yoga
|
||||||
- VisionCamera (4.7.3):
|
- VisionCamera (4.7.3):
|
||||||
- VisionCamera/Core (= 4.7.3)
|
- VisionCamera/Core (= 4.7.3)
|
||||||
- VisionCamera/React (= 4.7.3)
|
- VisionCamera/React (= 4.7.3)
|
||||||
|
|
@ -1961,6 +2049,7 @@ DEPENDENCIES:
|
||||||
- "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
- "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||||
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
||||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||||
|
- "op-sqlite (from `../node_modules/@op-engineering/op-sqlite`)"
|
||||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||||
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
|
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
|
||||||
- RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`)
|
- RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`)
|
||||||
|
|
@ -2000,6 +2089,8 @@ DEPENDENCIES:
|
||||||
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||||
- React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)
|
- React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)
|
||||||
- react-native-config (from `../node_modules/react-native-config`)
|
- react-native-config (from `../node_modules/react-native-config`)
|
||||||
|
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
|
||||||
|
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
|
||||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||||
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||||
- React-networking (from `../node_modules/react-native/ReactCommon/react/networking`)
|
- React-networking (from `../node_modules/react-native/ReactCommon/react/networking`)
|
||||||
|
|
@ -2036,6 +2127,7 @@ DEPENDENCIES:
|
||||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||||
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
|
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
|
||||||
- RNScreens (from `../node_modules/react-native-screens`)
|
- RNScreens (from `../node_modules/react-native-screens`)
|
||||||
|
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
|
||||||
- VisionCamera (from `../node_modules/react-native-vision-camera`)
|
- VisionCamera (from `../node_modules/react-native-vision-camera`)
|
||||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||||
|
|
||||||
|
|
@ -2047,6 +2139,8 @@ EXTERNAL SOURCES:
|
||||||
hermes-engine:
|
hermes-engine:
|
||||||
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
||||||
:tag: hermes-v250829098.0.10
|
:tag: hermes-v250829098.0.10
|
||||||
|
op-sqlite:
|
||||||
|
:path: "../node_modules/@op-engineering/op-sqlite"
|
||||||
RCTDeprecation:
|
RCTDeprecation:
|
||||||
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
||||||
RCTRequired:
|
RCTRequired:
|
||||||
|
|
@ -2123,6 +2217,10 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
|
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
|
||||||
react-native-config:
|
react-native-config:
|
||||||
:path: "../node_modules/react-native-config"
|
:path: "../node_modules/react-native-config"
|
||||||
|
react-native-get-random-values:
|
||||||
|
:path: "../node_modules/react-native-get-random-values"
|
||||||
|
react-native-netinfo:
|
||||||
|
:path: "../node_modules/@react-native-community/netinfo"
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
:path: "../node_modules/react-native-safe-area-context"
|
:path: "../node_modules/react-native-safe-area-context"
|
||||||
React-NativeModulesApple:
|
React-NativeModulesApple:
|
||||||
|
|
@ -2195,92 +2293,98 @@ EXTERNAL SOURCES:
|
||||||
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||||
RNScreens:
|
RNScreens:
|
||||||
:path: "../node_modules/react-native-screens"
|
:path: "../node_modules/react-native-screens"
|
||||||
|
RNVectorIcons:
|
||||||
|
:path: "../node_modules/react-native-vector-icons"
|
||||||
VisionCamera:
|
VisionCamera:
|
||||||
:path: "../node_modules/react-native-vision-camera"
|
:path: "../node_modules/react-native-vision-camera"
|
||||||
Yoga:
|
Yoga:
|
||||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
AsyncStorage: fb007da3d53332311126c6ce64e23765a04ecbe7
|
AsyncStorage: 097bd39cbb74dbfdeda2395deafdc09ed84561e3
|
||||||
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
|
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
|
||||||
hermes-engine: b8f76204869359f8bed36636ea3d6e542f026963
|
hermes-engine: b8f76204869359f8bed36636ea3d6e542f026963
|
||||||
|
op-sqlite: 1cd6211ea2d1986b07b11a26c1eda3e51e53c260
|
||||||
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
|
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
|
||||||
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
|
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
|
||||||
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
|
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
|
||||||
RCTSwiftUIWrapper: 7b8a1ffba8994a8f955c563511bffb74b4681317
|
RCTSwiftUIWrapper: 966ca7f5f22ac0b2b2255fb09cffc381f5440b03
|
||||||
RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2
|
RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2
|
||||||
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
|
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
|
||||||
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
|
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
|
||||||
React-Core: f90d375d3bab515ad00df30605ce1bf02e6db12f
|
React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
|
||||||
React-Core-prebuilt: 10f1a4faa8ebc8f2af5b577db5abd3e89e66ffa8
|
React-Core-prebuilt: 10f1a4faa8ebc8f2af5b577db5abd3e89e66ffa8
|
||||||
React-CoreModules: da4f80202ef954bdfb926ca4c9ac5f685900aa5c
|
React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
|
||||||
React-cxxreact: 1d1d2a28a5f10e4e2e7cf2bfd54dc9c92c68c672
|
React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
|
||||||
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
|
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
|
||||||
React-defaultsnativemodule: 172d2ef7d11c531c40ee74f3be1ac98d258a817f
|
React-defaultsnativemodule: cd64bc09d7ca24112bbaf1b91edbbcf3d81ea7dc
|
||||||
React-domnativemodule: 5a60a88075a35e20fa5879c94e7aa24e5f4071d0
|
React-domnativemodule: dacf5bc055ae041039574f38b73a20b91e368774
|
||||||
React-Fabric: 5c1954e06c094623e46d51da2dc2c926621c03a5
|
React-Fabric: bb0baa33d91839631d315800eb23e9aaa4338a44
|
||||||
React-FabricComponents: f32494150868073accd5d52d46b30a0496626813
|
React-FabricComponents: c504d0b0e2f3054b2ba19af839f175cb361153c0
|
||||||
React-FabricImage: dfcd2826b10f05c19e5bfa68d4937c4401c129db
|
React-FabricImage: 91eaea1cc58d25ae2596a9277bcfe028f92374c3
|
||||||
React-featureflags: 84bcfcb8f91f9475e437f3dd579399085a4af51e
|
React-featureflags: 5ac0455da0af12ca79b40402e2f42c5c7556b638
|
||||||
React-featureflagsnativemodule: 83111c4ee188b8859aaa8643c1be640442098fef
|
React-featureflagsnativemodule: df7da181b064f10f5959a7cd529b5aab3686ecb2
|
||||||
React-graphics: e0441bc695f5c682a3fb1b0fd317e10765700f12
|
React-graphics: d25b1195baf24c7918543f4aff9be89cc080906f
|
||||||
React-hermes: da795ff5d5816d8940fca927c46dcdd81d7e9ad6
|
React-hermes: 663286153a8ad6bf752b742654c766ff5e8991e7
|
||||||
React-idlecallbacksnativemodule: b6d79e1c9e335564e17d18e2649fe7524c4e74e4
|
React-idlecallbacksnativemodule: 0bd5392cb67f1ab25df736814b7c05213b1d3c68
|
||||||
React-ImageManager: 0e137d7231092ddaa236f3520b67b4aa833e7079
|
React-ImageManager: a03eed3e3d4222130dc0ad503a1a5f3aa89de746
|
||||||
React-intersectionobservernativemodule: 160b3c85bb5a3df2bf50e60619c0db50aadbef8c
|
React-intersectionobservernativemodule: 5d0f1c3c7b30031b0f6f730ee52dd9d607e2c966
|
||||||
React-jserrorhandler: 619d382632f5ed166541c03f7ba7deee76fb1b16
|
React-jserrorhandler: d5d6e7e20c5a2d6e8607e18d31d8712d7de676f6
|
||||||
React-jsi: eac528e72d25146faa922ef1aad82d338addbb75
|
React-jsi: eb7cc4cffcf24796cc302d5b2bca0e92544139a9
|
||||||
React-jsiexecutor: 2d3000fdde95d0da451f8976cdf9018448756023
|
React-jsiexecutor: 65006f60e64c72c6b82f62ef6bd17c84846e73f8
|
||||||
React-jsinspector: 0377987f9635c64c5aaf72ec73aaf2b0b0da7744
|
React-jsinspector: 01e32e2247b2486117fbb143db7f7717ef462c6d
|
||||||
React-jsinspectorcdp: 9db641a9cd0dd5b693df27d2e665ac2540ddc336
|
React-jsinspectorcdp: f1cbb34ca41d188ba22efd9b663cf258a911f6cd
|
||||||
React-jsinspectornetwork: 61b00d0adfe6f175830660f1b98da576bc74eb0a
|
React-jsinspectornetwork: f61acc94c881c41451f508abfe6efa748b956c21
|
||||||
React-jsinspectortracing: 0f8d4f2ebd7523aece78cbc926cd4b6f2fc227dc
|
React-jsinspectortracing: 9395894d9bd4d17931b9afcf230c0e7f4cb3d674
|
||||||
React-jsitooling: 36f3854063d507bc4d4a01227ebf1b63d9e24592
|
React-jsitooling: 03ead841daa12a93b18479f5e400ceab3732d36d
|
||||||
React-jsitracing: cddf044c0c2847d3f5822a984018fd5596c1d448
|
React-jsitracing: 4ae61c79e14360d1c6ab566031c62c490da78439
|
||||||
React-logger: 57001aefabd79d885589d2f31a8be05ccc393eaa
|
React-logger: bf149dea4343a9037b74bade36cced8b63f03f46
|
||||||
React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71
|
React-Mapbuffer: fec3e025f0ffba6b32cd2a1d7bbdee3e269aae90
|
||||||
React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21
|
React-microtasksnativemodule: ab33a818d339f5a1da308893c11b487be66121a8
|
||||||
React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741
|
React-mutationobservernativemodule: a42d1626651ccd7d0dc02a56e69d4ec77c248893
|
||||||
react-native-config: 93721be89c97d983d5a90e2f3e692c8ff1d95720
|
react-native-config: 6188f674e4f4b3bdfd43c22886fb04f3103793ff
|
||||||
react-native-safe-area-context: c1eb308f4b36372a4de4b3bdaa8ed695ec3dd461
|
react-native-get-random-values: 68792987aef40e8aa72dc448d97e009d1f440c88
|
||||||
React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56
|
react-native-netinfo: 2f906d5d9a639090ed1c079135d6485a95f749f1
|
||||||
React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e
|
react-native-safe-area-context: fb5c8ee9f6dd62ef710611b3d370c501f42a4ac0
|
||||||
|
React-NativeModulesApple: deba264b03bd79c6bd61014fa30e40321b5e443a
|
||||||
|
React-networking: 35e6070b084f435429f85c5db40b4d5b38652fe9
|
||||||
React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e
|
React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e
|
||||||
React-perflogger: b2b0144e4ba8dd157c1e90f8ebb02d22edbd040d
|
React-perflogger: ca04287f205086a1edb5c95882be7b6068458889
|
||||||
React-performancecdpmetrics: 5a715ec33f2dea48a93a70db37a78b4f51f9fd5a
|
React-performancecdpmetrics: cf1d0a3178ccd59353cedcacbda421f40100a889
|
||||||
React-performancetimeline: c292077a6fbc7f423269b01aab0fb7cc48efe3c0
|
React-performancetimeline: c9771212e7a43032d6f8d5edfb58280d46a7ce1f
|
||||||
React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884
|
React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884
|
||||||
React-RCTAnimation: 1f9a58c7b74c25ea4ca6e6fbd05f37cc4919097a
|
React-RCTAnimation: 343147a9cd68c93d0ca280799fedfc7102d76ce4
|
||||||
React-RCTAppDelegate: 856f82c57b47c1795009af585cfb7b87fe2d3b5a
|
React-RCTAppDelegate: 5054754e92aaa9f8bfabe0f1022b84e46f3dcb57
|
||||||
React-RCTBlob: 1cb18861a19be0b4d7e44bed9315e791413399f9
|
React-RCTBlob: 8c7ae3422ca4e72bc64b7a0142fd730efc5d4dfb
|
||||||
React-RCTFabric: 081853d9efb9b38fa868c1ff3d60e48106fe2a24
|
React-RCTFabric: be458db054b206c4d8e4f20f666e75d5f2c2d420
|
||||||
React-RCTFBReactNativeSpec: cbc760c7a889bfce20746ca0037b97c10ea58665
|
React-RCTFBReactNativeSpec: 06db2e8d0f352d9fa23321aed1dd2cde25a3e83c
|
||||||
React-RCTImage: 0d94b22644ca87fcb778a8fa3ffbf990bc9028df
|
React-RCTImage: 481457bca63e039eb997f7d16c7560472f49657e
|
||||||
React-RCTLinking: 881a0c6772dd05a14ab0edfea05dadb698ec8090
|
React-RCTLinking: 76cbb871240cec2dc5e7aa26c60f59e0ebbcf5a2
|
||||||
React-RCTNetwork: feb412861e3fff3426eb0961c2acdd96bee8dce1
|
React-RCTNetwork: e23a778225b7672e38545d3c5c24e1f4aad6d15f
|
||||||
React-RCTRuntime: c61b848ffadc0209fe643406370d58021bd3585d
|
React-RCTRuntime: 93c830b3ab3f7b494bbe7ae7289784f8b07b3947
|
||||||
React-RCTSettings: b6e14b8764f807ebe9be2e7f0d72be83b359ac26
|
React-RCTSettings: 96196b535bef147381f96cc60ce9bda85d8be848
|
||||||
React-RCTText: 1ecd4f85ff609183ebec858cf94c0f27a9dec163
|
React-RCTText: 749ebbd1a999fd84d80f37002ea3bf597fcea6df
|
||||||
React-RCTVibration: 3611aa5cb59094632b3141d72c50cbb8ce3d5b08
|
React-RCTVibration: 5b41a7f274757c2928845981d970916ef9e4ca14
|
||||||
React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f
|
React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f
|
||||||
React-renderercss: d4a70898381431dcc07d6deba94a7eaef0cc9058
|
React-renderercss: 80eb778756fed511d6128fc005188ac9008b0baf
|
||||||
React-rendererdebug: ad92235a960983f69183e2e59e2bce21e72c80a0
|
React-rendererdebug: b46f338fb9d3f0bea6cf0621016c6c5a7a18e72e
|
||||||
React-RuntimeApple: 53a5b8fe60b044ec6fd4d254d66b7da69314a295
|
React-RuntimeApple: c494b2089fad4a0c553cf63c2bb265f7eca2285d
|
||||||
React-RuntimeCore: 97e125471c0b8313db2bbeb1e9dc001a5503e979
|
React-RuntimeCore: b0bb151c3e2b26c6309d45d05c54aa65a6e0c094
|
||||||
React-runtimeexecutor: 0ff42dcf1cc4bc7a89d29532a16a7d2d3849fb2f
|
React-runtimeexecutor: 00b18635b6216a1708f6eb35dbadfd993ec91c7b
|
||||||
React-RuntimeHermes: da5a1b7e39f3db1a7b3303d75d4c5bb6cd7c0d49
|
React-RuntimeHermes: bb44c4c574ce1b9507cad2e6be015344d18b94a9
|
||||||
React-runtimescheduler: 0e24c80f0c8b6e822a33e4ad89e0ab555704eedb
|
React-runtimescheduler: e1631e57209cb94b3efc29002b6a049cac3f6599
|
||||||
React-timing: f23e82ad46673716e34a786048414ab80f237594
|
React-timing: 356b88317ca60d373b0d94b6e7a71b0a572899f5
|
||||||
React-utils: 2851415c698da4b18331f9a445825d260fffa32c
|
React-utils: ccc01da318979af773259c4f6cdb1876f6f86f1a
|
||||||
React-webperformancenativemodule: 9f29ed34f6f6c01dac688b95fd2dfcbccf3aed7a
|
React-webperformancenativemodule: f8b97c2cb6cfa94e92a503c09ad6d491c50a1390
|
||||||
ReactAppDependencyProvider: a54c0c9b976766e1b6d5e2bb4d1ad0d15913e9e1
|
ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2
|
||||||
ReactCodegen: 9af5798e943781fd2318dab7b60f25337388047c
|
ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014
|
||||||
ReactCommon: 3ec2a55999d296af90c24beb66c8a77dc660d3ef
|
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
|
||||||
ReactNativeDependencies: 8c3f5cf0bb3f36f1832b653f5689626379a12766
|
ReactNativeDependencies: 8c3f5cf0bb3f36f1832b653f5689626379a12766
|
||||||
RNScreens: b612be2401172d6666143db20a0f70f638c4619b
|
RNScreens: 75d04ea58bd7a8e8ac60b7968d0382aa71631a7d
|
||||||
VisionCamera: 3ea10c46a5c612f5f89fb46a54bef4a0de8b58a7
|
RNVectorIcons: 97f26211c07d69e45b189f9dd0dbbe5e2d2b0b92
|
||||||
Yoga: 36fee8f1fca3f54a28c3d7f80e69f66d73d3af96
|
VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f
|
||||||
|
Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b
|
||||||
|
|
||||||
PODFILE CHECKSUM: 0943ebd85d20bed58095755f929cbce4d6306cad
|
PODFILE CHECKSUM: 0943ebd85d20bed58095755f929cbce4d6306cad
|
||||||
|
|
||||||
COCOAPODS: 1.15.2
|
COCOAPODS: 1.16.2
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,20 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@op-engineering/op-sqlite": "^17.1.5",
|
||||||
"@react-native-async-storage/async-storage": "^3.0.3",
|
"@react-native-async-storage/async-storage": "^3.0.3",
|
||||||
|
"@react-native-community/netinfo": "^12.0.1",
|
||||||
"@react-navigation/native": "^7.2.4",
|
"@react-navigation/native": "^7.2.4",
|
||||||
"@react-navigation/native-stack": "^7.15.1",
|
"@react-navigation/native-stack": "^7.15.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-config": "^1.6.1",
|
"react-native-config": "^1.6.1",
|
||||||
|
"react-native-get-random-values": "^2.0.0",
|
||||||
"react-native-safe-area-context": "^5.5.2",
|
"react-native-safe-area-context": "^5.5.2",
|
||||||
"react-native-screens": "^4.25.1",
|
"react-native-screens": "^4.25.1",
|
||||||
"react-native-vision-camera": "4.7.3"
|
"react-native-vector-icons": "^10.3.0",
|
||||||
|
"react-native-vision-camera": "4.7.3",
|
||||||
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|
@ -33,7 +38,9 @@
|
||||||
"@react-native/typescript-config": "0.85.3",
|
"@react-native/typescript-config": "0.85.3",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"jest": "^29.6.3",
|
"jest": "^29.6.3",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
|
|
@ -2770,6 +2777,26 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@op-engineering/op-sqlite": {
|
||||||
|
"version": "17.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@op-engineering/op-sqlite/-/op-sqlite-17.1.5.tgz",
|
||||||
|
"integrity": "sha512-Al9YjK9EVYr1zLVLffMZkWHNfGScxe1B1NdmeKZ+pdk8bOX8wdE1du73+sk8humoR90OL+xOgMhdOteSQVubEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"example",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@sqlite.org/sqlite-wasm": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@sqlite.org/sqlite-wasm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native-async-storage/async-storage": {
|
"node_modules/@react-native-async-storage/async-storage": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.3.tgz",
|
||||||
|
|
@ -3016,6 +3043,16 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-community/netinfo": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": ">=0.59"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.85.3",
|
"version": "0.85.3",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz",
|
||||||
|
|
@ -3638,6 +3675,27 @@
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-native": {
|
||||||
|
"version": "0.70.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz",
|
||||||
|
"integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-native-vector-icons": {
|
||||||
|
"version": "6.4.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz",
|
||||||
|
"integrity": "sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-native": "^0.70"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react-test-renderer": {
|
"node_modules/@types/react-test-renderer": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||||
|
|
@ -3655,6 +3713,13 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
|
|
@ -6377,6 +6442,12 @@
|
||||||
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
|
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-base64-decode": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -9597,7 +9668,6 @@
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -10133,7 +10203,6 @@
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
|
|
@ -10145,7 +10214,6 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
|
|
@ -10397,6 +10465,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-get-random-values": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-base64-decode": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.81"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-safe-area-context": {
|
"node_modules/react-native-safe-area-context": {
|
||||||
"version": "5.8.0",
|
"version": "5.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
|
||||||
|
|
@ -10421,6 +10501,61 @@
|
||||||
"react-native": ">=0.82.0"
|
"react-native": ">=0.82.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-vector-icons": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==",
|
||||||
|
"deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"yargs": "^16.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fa-upgrade.sh": "bin/fa-upgrade.sh",
|
||||||
|
"fa5-upgrade": "bin/fa5-upgrade.sh",
|
||||||
|
"fa6-upgrade": "bin/fa6-upgrade.sh",
|
||||||
|
"generate-icon": "bin/generate-icon.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-vector-icons/node_modules/cliui": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-vector-icons/node_modules/yargs": {
|
||||||
|
"version": "16.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
|
||||||
|
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^7.0.2",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^20.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-vector-icons/node_modules/yargs-parser": {
|
||||||
|
"version": "20.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
|
||||||
|
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-vision-camera": {
|
"node_modules/react-native-vision-camera": {
|
||||||
"version": "4.7.3",
|
"version": "4.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz",
|
||||||
|
|
@ -12069,6 +12204,19 @@
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v8-to-istanbul": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -4,6 +4,8 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
"android:share-apk": "cd android && ./gradlew assembleDebug -PreactNativeArchitectures=arm64-v8a",
|
||||||
|
"android:reset-build": "rm -rf android/app/.cxx android/app/build android/build && cd android && ./gradlew assembleDebug -PreactNativeArchitectures=arm64-v8a",
|
||||||
"ios": "react-native run-ios",
|
"ios": "react-native run-ios",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"postinstall": "node scripts/patch-metro-index.js",
|
"postinstall": "node scripts/patch-metro-index.js",
|
||||||
|
|
@ -11,15 +13,20 @@
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@op-engineering/op-sqlite": "^17.1.5",
|
||||||
"@react-native-async-storage/async-storage": "^3.0.3",
|
"@react-native-async-storage/async-storage": "^3.0.3",
|
||||||
|
"@react-native-community/netinfo": "^12.0.1",
|
||||||
"@react-navigation/native": "^7.2.4",
|
"@react-navigation/native": "^7.2.4",
|
||||||
"@react-navigation/native-stack": "^7.15.1",
|
"@react-navigation/native-stack": "^7.15.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-config": "^1.6.1",
|
"react-native-config": "^1.6.1",
|
||||||
|
"react-native-get-random-values": "^2.0.0",
|
||||||
"react-native-safe-area-context": "^5.5.2",
|
"react-native-safe-area-context": "^5.5.2",
|
||||||
"react-native-screens": "^4.25.1",
|
"react-native-screens": "^4.25.1",
|
||||||
"react-native-vision-camera": "4.7.3"
|
"react-native-vector-icons": "^10.3.0",
|
||||||
|
"react-native-vision-camera": "4.7.3",
|
||||||
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|
@ -35,7 +42,9 @@
|
||||||
"@react-native/typescript-config": "0.85.3",
|
"@react-native/typescript-config": "0.85.3",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"jest": "^29.6.3",
|
"jest": "^29.6.3",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type {
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
AttendanceSearchParticipant,
|
AttendanceSearchParticipant,
|
||||||
AttendanceCheckInResult,
|
AttendanceCheckInResult,
|
||||||
|
OfflineSyncOperation,
|
||||||
|
OfflineSyncResponse,
|
||||||
ParticipantPreview,
|
ParticipantPreview,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
|
@ -30,4 +32,15 @@ export const attendancesApi = {
|
||||||
{ participantId },
|
{ participantId },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncCheckIns(
|
||||||
|
eventId: string,
|
||||||
|
attendanceId: string,
|
||||||
|
body: { deviceId?: string; operations: OfflineSyncOperation[] },
|
||||||
|
) {
|
||||||
|
return api.post<OfflineSyncResponse>(
|
||||||
|
`/events/${eventId}/attendances/${attendanceId}/check-ins/sync`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,24 @@ export interface AttendanceCheckInResult {
|
||||||
alreadyCheckedIn: boolean;
|
alreadyCheckedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OfflineSyncOperation {
|
||||||
|
clientOpId: string;
|
||||||
|
participantId: string;
|
||||||
|
checkedInAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineSyncOpResult {
|
||||||
|
clientOpId: string;
|
||||||
|
status: 'applied' | 'duplicate' | 'conflict' | 'rejected';
|
||||||
|
checkInCount?: number;
|
||||||
|
lastCheckedInAt?: string | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineSyncResponse {
|
||||||
|
results: OfflineSyncOpResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParticipantPreview {
|
export interface ParticipantPreview {
|
||||||
_id: string;
|
_id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,47 +1,91 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { AttendanceCheckInResult } from '../api/types';
|
import type { AttendanceCheckInResult } from '../api/types';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, spacing, typography } from '../theme';
|
||||||
import { Card, PrimaryButton } from './ui';
|
import { AppIcon, PrimaryButton } from './ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
result: AttendanceCheckInResult | null;
|
result: AttendanceCheckInResult | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
pendingSync?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CheckInResultModal({ visible, result, error, onClose }: Props) {
|
const CHECKIN_GREEN = '#16A34A';
|
||||||
const success = Boolean(result && !error);
|
const PENDING_AMBER = '#D97706';
|
||||||
|
|
||||||
|
export function CheckInResultModal({
|
||||||
|
visible,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
pendingSync = false,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<Pressable style={styles.overlay} onPress={onClose}>
|
<Pressable style={styles.overlay} onPress={onClose}>
|
||||||
<Pressable onPress={(e) => e.stopPropagation()}>
|
<Pressable
|
||||||
<Card style={styles.card} padded>
|
style={[styles.sheet, { paddingBottom: Math.max(insets.bottom, spacing.lg) }]}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<>
|
<>
|
||||||
|
<View style={styles.errorIconWrap}>
|
||||||
|
<AppIcon name="close-circle-outline" size={36} color={colors.error} />
|
||||||
|
</View>
|
||||||
<Text style={styles.titleError}>Check-in failed</Text>
|
<Text style={styles.titleError}>Check-in failed</Text>
|
||||||
<Text style={styles.body}>{error}</Text>
|
<Text style={styles.body}>{error}</Text>
|
||||||
</>
|
</>
|
||||||
) : result ? (
|
) : result ? (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.titleSuccess}>Checked in</Text>
|
<View style={styles.successIconWrap}>
|
||||||
|
<AppIcon name="checkmark-circle-outline" size={36} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.titleSuccess}>Checked In!</Text>
|
||||||
|
|
||||||
|
<View style={styles.detailCard}>
|
||||||
<Text style={styles.name}>{result.displayName}</Text>
|
<Text style={styles.name}>{result.displayName}</Text>
|
||||||
{result.email ? <Text style={styles.meta}>{result.email}</Text> : null}
|
{result.email ? (
|
||||||
{result.phone ? <Text style={styles.meta}>{result.phone}</Text> : null}
|
<View style={styles.detailRow}>
|
||||||
<Text style={styles.meta}>
|
<AppIcon name="mail-outline" size={16} color={colors.primary} />
|
||||||
Session: {result.attendanceName}
|
<Text style={styles.detailText}>{result.email}</Text>
|
||||||
</Text>
|
</View>
|
||||||
|
) : null}
|
||||||
|
{result.phone ? (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<AppIcon name="call-outline" size={16} color={colors.primary} />
|
||||||
|
<Text style={styles.detailText}>{result.phone}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<AppIcon name="qr-code-outline" size={16} color={colors.primary} />
|
||||||
|
<Text style={styles.detailText}>Session: {result.attendanceName}</Text>
|
||||||
|
</View>
|
||||||
{result.lastCheckedInAt ? (
|
{result.lastCheckedInAt ? (
|
||||||
<Text style={styles.meta}>
|
<View style={styles.detailRow}>
|
||||||
|
<AppIcon name="time-outline" size={16} color={colors.primary} />
|
||||||
|
<Text style={styles.detailText}>
|
||||||
{new Date(result.lastCheckedInAt).toLocaleString()}
|
{new Date(result.lastCheckedInAt).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
{pendingSync ? (
|
||||||
|
<View style={styles.pendingBanner}>
|
||||||
|
<AppIcon name="cloud-offline-outline" size={16} color={PENDING_AMBER} />
|
||||||
|
<Text style={styles.pendingText}>
|
||||||
|
Saved offline — will sync when you are back online
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<PrimaryButton title="Done" onPress={onClose} />
|
|
||||||
</Card>
|
<PrimaryButton title="Done" onPress={onClose} style={styles.doneBtn} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -51,37 +95,89 @@ export function CheckInResultModal({ visible, result, error, onClose }: Props) {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
justifyContent: 'center',
|
justifyContent: 'flex-end',
|
||||||
padding: 24,
|
|
||||||
},
|
},
|
||||||
card: {
|
sheet: {
|
||||||
borderRadius: radii.xl,
|
backgroundColor: '#fff',
|
||||||
borderWidth: 0,
|
borderTopLeftRadius: 28,
|
||||||
|
borderTopRightRadius: 28,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingTop: spacing.xl,
|
||||||
|
},
|
||||||
|
successIconWrap: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: CHECKIN_GREEN,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
errorIconWrap: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
titleSuccess: {
|
titleSuccess: {
|
||||||
...typography.h2,
|
fontSize: 20,
|
||||||
color: colors.success,
|
fontWeight: '700',
|
||||||
marginBottom: 12,
|
color: CHECKIN_GREEN,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
titleError: {
|
titleError: {
|
||||||
...typography.h2,
|
...typography.h2,
|
||||||
color: colors.error,
|
color: colors.error,
|
||||||
marginBottom: 12,
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
...typography.h3,
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
detailCard: {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
|
detailText: {
|
||||||
|
flex: 1,
|
||||||
|
...typography.small,
|
||||||
|
color: colors.muted,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
body: {
|
body: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginBottom: 16,
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
meta: {
|
pendingBanner: {
|
||||||
...typography.small,
|
flexDirection: 'row',
|
||||||
color: colors.muted,
|
alignItems: 'flex-start',
|
||||||
marginBottom: 4,
|
gap: 8,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
pendingText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: PENDING_AMBER,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
doneBtn: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { StyleProp, TextStyle } from 'react-native';
|
||||||
|
import Ionicons from 'react-native-vector-icons/Ionicons';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
|
||||||
|
export type AppIconName =
|
||||||
|
| 'arrow-back'
|
||||||
|
| 'business-outline'
|
||||||
|
| 'calendar-outline'
|
||||||
|
| 'call-outline'
|
||||||
|
| 'checkmark-circle-outline'
|
||||||
|
| 'chevron-forward-outline'
|
||||||
|
| 'close-circle-outline'
|
||||||
|
| 'cloud-done-outline'
|
||||||
|
| 'cloud-download-outline'
|
||||||
|
| 'cloud-offline-outline'
|
||||||
|
| 'cloud-upload-outline'
|
||||||
|
| 'enter-outline'
|
||||||
|
| 'eye-off-outline'
|
||||||
|
| 'eye-outline'
|
||||||
|
| 'location-outline'
|
||||||
|
| 'lock-closed-outline'
|
||||||
|
| 'log-out-outline'
|
||||||
|
| 'mail-outline'
|
||||||
|
| 'people-outline'
|
||||||
|
| 'person-outline'
|
||||||
|
| 'qr-code-outline'
|
||||||
|
| 'refresh-outline'
|
||||||
|
| 'search-outline'
|
||||||
|
| 'time-outline';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: AppIconName;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppIcon({ name, size = 18, color = colors.muted, style }: Props) {
|
||||||
|
return <Ionicons name={name} size={size} color={color} style={style} />;
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,33 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import type { ViewStyle } from 'react-native';
|
import type { ViewStyle } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { colors, spacing, typography } from '../../theme';
|
import { colors, spacing, typography } from '../../theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
onBack?: () => void;
|
||||||
right?: React.ReactNode;
|
right?: React.ReactNode;
|
||||||
bottom?: React.ReactNode;
|
bottom?: React.ReactNode;
|
||||||
style?: ViewStyle | ViewStyle[];
|
style?: ViewStyle | ViewStyle[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GradientHeader({ title, subtitle, right, bottom, style }: Props) {
|
export function GradientHeader({ title, subtitle, onBack, right, bottom, style }: Props) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.wrap, style]}>
|
<View style={[styles.wrap, { paddingTop: insets.top + spacing.md }, style]}>
|
||||||
{/* Faux “gradient”: solid orange with decorative circles. Replace with LinearGradient once deps install. */}
|
|
||||||
<View style={styles.bg} />
|
<View style={styles.bg} />
|
||||||
<View style={styles.circleA} />
|
<View style={styles.circleA} />
|
||||||
<View style={styles.circleB} />
|
<View style={styles.circleB} />
|
||||||
|
|
||||||
|
{onBack ? (
|
||||||
|
<Pressable onPress={onBack} hitSlop={12} style={styles.backRow}>
|
||||||
|
<Text style={styles.backText}>‹ Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={styles.topRow}>
|
<View style={styles.topRow}>
|
||||||
<View style={styles.textCol}>
|
<View style={styles.textCol}>
|
||||||
{title ? <Text style={styles.title}>{title}</Text> : null}
|
{title ? <Text style={styles.title}>{title}</Text> : null}
|
||||||
|
|
@ -34,14 +43,14 @@ export function GradientHeader({ title, subtitle, right, bottom, style }: Props)
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: {
|
wrap: {
|
||||||
paddingTop: 52,
|
|
||||||
paddingBottom: 18,
|
paddingBottom: 18,
|
||||||
paddingHorizontal: spacing.xl,
|
paddingHorizontal: spacing.xl,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
bg: {
|
bg: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFill,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
|
zIndex: 0,
|
||||||
},
|
},
|
||||||
circleA: {
|
circleA: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -51,6 +60,7 @@ const styles = StyleSheet.create({
|
||||||
right: -40,
|
right: -40,
|
||||||
top: -40,
|
top: -40,
|
||||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||||
|
zIndex: 0,
|
||||||
},
|
},
|
||||||
circleB: {
|
circleB: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -60,12 +70,24 @@ const styles = StyleSheet.create({
|
||||||
left: -24,
|
left: -24,
|
||||||
bottom: -32,
|
bottom: -32,
|
||||||
backgroundColor: 'rgba(255,255,255,0.10)',
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
backRow: {
|
||||||
|
marginBottom: 10,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
backText: {
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
topRow: {
|
topRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
textCol: { flex: 1 },
|
textCol: { flex: 1 },
|
||||||
title: {
|
title: {
|
||||||
|
|
@ -77,7 +99,6 @@ const styles = StyleSheet.create({
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: 'rgba(255,255,255,0.75)',
|
color: 'rgba(255,255,255,0.75)',
|
||||||
},
|
},
|
||||||
right: { alignItems: 'flex-end' },
|
right: { alignItems: 'flex-end', zIndex: 1 },
|
||||||
bottom: { marginTop: 14 },
|
bottom: { marginTop: 14, zIndex: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,7 @@ export function PrimaryButton({ title, loading, disabled, style, ...props }: Pro
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Faux “gradient”: solid base + darker highlight strip. Replace with LinearGradient once deps install. */}
|
|
||||||
<View style={styles.bg} />
|
<View style={styles.bg} />
|
||||||
<View style={styles.highlight} />
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.text}>{title}</Text>}
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.text}>{title}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -42,18 +40,9 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
bg: {
|
bg: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFill,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
},
|
},
|
||||||
highlight: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: '55%',
|
|
||||||
backgroundColor: colors.primaryDark,
|
|
||||||
opacity: 0.35,
|
|
||||||
},
|
|
||||||
content: {
|
content: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
import type { TextInputProps, ViewStyle } from 'react-native';
|
import type { TextInputProps, ViewStyle } from 'react-native';
|
||||||
import { colors, radii, typography } from '../../theme';
|
import { colors, radii, typography } from '../../theme';
|
||||||
|
|
@ -10,22 +10,36 @@ type Props = TextInputProps & {
|
||||||
containerStyle?: ViewStyle | ViewStyle[];
|
containerStyle?: ViewStyle | ViewStyle[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TextField({ label, left, right, containerStyle, style, ...props }: Props) {
|
export const TextField = React.forwardRef<TextInput, Props>(function TextField(
|
||||||
|
{ label, left, right, containerStyle, style, onFocus, onBlur, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
{label ? <Text style={styles.label}>{label}</Text> : null}
|
{label ? <Text style={[styles.label, focused && styles.labelFocused]}>{label}</Text> : null}
|
||||||
<View style={styles.row}>
|
<View style={[styles.row, focused && styles.rowFocused]}>
|
||||||
{left ? <View style={styles.side}>{left}</View> : null}
|
{left ? <View style={styles.side}>{left}</View> : null}
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
style={[styles.input, style]}
|
onFocus={(e) => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur?.(e);
|
||||||
|
}}
|
||||||
|
style={[styles.input, focused && styles.inputFocused, style]}
|
||||||
placeholderTextColor={colors.muted}
|
placeholderTextColor={colors.muted}
|
||||||
/>
|
/>
|
||||||
{right ? <View style={styles.side}>{right}</View> : null}
|
{right ? <View style={styles.side}>{right}</View> : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
label: {
|
label: {
|
||||||
|
|
@ -33,6 +47,9 @@ const styles = StyleSheet.create({
|
||||||
color: colors.muted,
|
color: colors.muted,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
|
labelFocused: {
|
||||||
|
color: colors.primaryDark,
|
||||||
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -40,10 +57,14 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderRadius: radii.md,
|
borderRadius: radii.md,
|
||||||
borderWidth: 1,
|
borderWidth: 2,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
backgroundColor: '#F9FAFB',
|
backgroundColor: '#F9FAFB',
|
||||||
},
|
},
|
||||||
|
rowFocused: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
side: { alignItems: 'center', justifyContent: 'center' },
|
side: { alignItems: 'center', justifyContent: 'center' },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -51,5 +72,7 @@ const styles = StyleSheet.create({
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
},
|
},
|
||||||
|
inputFocused: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from './AppIcon';
|
||||||
export * from './Card';
|
export * from './Card';
|
||||||
export * from './PrimaryButton';
|
export * from './PrimaryButton';
|
||||||
export * from './TextField';
|
export * from './TextField';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, Platform, StatusBar, View } from 'react-native';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { LoginScreen } from '../screens/LoginScreen';
|
import { LoginScreen } from '../screens/LoginScreen';
|
||||||
|
|
@ -14,15 +14,60 @@ import { colors } from '../theme';
|
||||||
import { getAccessToken } from '../api/client';
|
import { getAccessToken } from '../api/client';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||||
|
|
||||||
|
/** Android only — iOS uses <StatusBar> in App.tsx to avoid react-native-screens plist assertion. */
|
||||||
|
const androidOrangeStatusBar =
|
||||||
|
Platform.OS === 'android'
|
||||||
|
? {
|
||||||
|
statusBarBackgroundColor: colors.primary,
|
||||||
|
statusBarStyle: 'light' as const,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
/** iOS 26+ scroll edge effects draw a white veil over custom headers and buttons. */
|
||||||
|
const stackScreenOptions = {
|
||||||
|
headerShown: false as const,
|
||||||
|
contentStyle: { backgroundColor: colors.background },
|
||||||
|
...androidOrangeStatusBar,
|
||||||
|
...(Platform.OS === 'ios'
|
||||||
|
? {
|
||||||
|
scrollEdgeEffects: {
|
||||||
|
top: 'hidden' as const,
|
||||||
|
bottom: 'hidden' as const,
|
||||||
|
left: 'hidden' as const,
|
||||||
|
right: 'hidden' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
export function RootNavigator() {
|
export function RootNavigator() {
|
||||||
const { loading, user } = useAuth();
|
const { loading, user } = useAuth();
|
||||||
const hasToken = Boolean(getAccessToken());
|
const hasToken = Boolean(getAccessToken());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || user || hasToken) return;
|
||||||
|
if (navigationRef.isReady()) {
|
||||||
|
navigationRef.reset({
|
||||||
|
index: 0,
|
||||||
|
routes: [{ name: 'Login' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loading, user, hasToken]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
<View
|
||||||
<ActivityIndicator size="large" color={colors.primary} />
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={colors.primary} />
|
||||||
|
<ActivityIndicator size="large" color="#fff" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -30,44 +75,31 @@ export function RootNavigator() {
|
||||||
const initialRoute = user || hasToken ? 'Events' : 'Login';
|
const initialRoute = user || hasToken ? 'Events' : 'Login';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer>
|
<NavigationContainer ref={navigationRef}>
|
||||||
<Stack.Navigator
|
<Stack.Navigator initialRouteName={initialRoute} screenOptions={stackScreenOptions}>
|
||||||
initialRouteName={initialRoute}
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: { backgroundColor: colors.card },
|
|
||||||
headerTintColor: colors.primaryDark,
|
|
||||||
headerTitleStyle: { fontWeight: '700', color: colors.text },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Login"
|
name="Login"
|
||||||
component={LoginScreen}
|
component={LoginScreen}
|
||||||
options={{ headerShown: false }}
|
options={{
|
||||||
/>
|
contentStyle: { backgroundColor: colors.primary },
|
||||||
<Stack.Screen
|
...androidOrangeStatusBar,
|
||||||
name="Login2FA"
|
}}
|
||||||
component={Login2FAScreen}
|
|
||||||
options={{ title: '2FA', headerShown: false }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="Events"
|
|
||||||
component={EventsScreen}
|
|
||||||
options={{ headerShown: false }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="AttendanceSessions"
|
|
||||||
component={AttendanceSessionsScreen}
|
|
||||||
options={{ headerShown: false }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="Participants"
|
|
||||||
component={ParticipantsScreen}
|
|
||||||
options={{ headerShown: false }}
|
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="Login2FA" component={Login2FAScreen} />
|
||||||
|
<Stack.Screen name="Events" component={EventsScreen} />
|
||||||
|
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
|
||||||
|
<Stack.Screen name="Participants" component={ParticipantsScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="QrScanner"
|
name="QrScanner"
|
||||||
component={QrScannerScreen}
|
component={QrScannerScreen}
|
||||||
options={{ title: 'Scan QR', headerShown: false }}
|
options={
|
||||||
|
Platform.OS === 'android'
|
||||||
|
? {
|
||||||
|
statusBarBackgroundColor: '#000000',
|
||||||
|
statusBarStyle: 'light' as const,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { AppState, type AppStateStatus } from 'react-native';
|
||||||
|
import { getDb } from './db';
|
||||||
|
import { runSync, subscribeSyncEngine } from './syncEngine';
|
||||||
|
import type { EventOfflineStatus } from './types';
|
||||||
|
import { getEventOfflineStatus, listOfflineEventIds } from './repos';
|
||||||
|
|
||||||
|
type OfflineContextValue = {
|
||||||
|
ready: boolean;
|
||||||
|
isOnline: boolean;
|
||||||
|
syncing: boolean;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
statusByEvent: Record<string, EventOfflineStatus>;
|
||||||
|
refreshStatuses: () => Promise<void>;
|
||||||
|
syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OfflineContext = createContext<OfflineContextValue | null>(null);
|
||||||
|
|
||||||
|
export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [lastSyncError, setLastSyncError] = useState<string | null>(null);
|
||||||
|
const [statusByEvent, setStatusByEvent] = useState<Record<string, EventOfflineStatus>>({});
|
||||||
|
|
||||||
|
const refreshStatuses = useCallback(async () => {
|
||||||
|
const ids = await listOfflineEventIds();
|
||||||
|
const next: Record<string, EventOfflineStatus> = {};
|
||||||
|
await Promise.all(
|
||||||
|
ids.map(async (id) => {
|
||||||
|
next[id] = await getEventOfflineStatus(id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setStatusByEvent(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncNow = useCallback(
|
||||||
|
async (eventId?: string) => {
|
||||||
|
const result = await runSync(eventId);
|
||||||
|
await refreshStatuses();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[refreshStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await getDb();
|
||||||
|
if (!mounted) return;
|
||||||
|
setReady(true);
|
||||||
|
await refreshStatuses();
|
||||||
|
} catch {
|
||||||
|
if (mounted) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [refreshStatuses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeSyncEngine(({ syncing: s, lastError }) => {
|
||||||
|
setSyncing(s);
|
||||||
|
if (lastError) setLastSyncError(lastError);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = NetInfo.addEventListener((state) => {
|
||||||
|
const online = Boolean(state.isConnected && state.isInternetReachable !== false);
|
||||||
|
setIsOnline(online);
|
||||||
|
if (online) {
|
||||||
|
syncNow().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, [syncNow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onAppState = (next: AppStateStatus) => {
|
||||||
|
if (next === 'active') {
|
||||||
|
syncNow().catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sub = AppState.addEventListener('change', onAppState);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [syncNow]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
ready,
|
||||||
|
isOnline,
|
||||||
|
syncing,
|
||||||
|
lastSyncError,
|
||||||
|
statusByEvent,
|
||||||
|
refreshStatuses,
|
||||||
|
syncNow,
|
||||||
|
}),
|
||||||
|
[ready, isOnline, syncing, lastSyncError, statusByEvent, refreshStatuses, syncNow],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <OfflineContext.Provider value={value}>{children}</OfflineContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOffline(): OfflineContextValue {
|
||||||
|
const ctx = useContext(OfflineContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useOffline must be used within OfflineProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { attendancesApi } from '../api/attendances';
|
||||||
|
import type { AttendanceCheckInResult } from '../api/types';
|
||||||
|
import { getDeviceId } from './deviceId';
|
||||||
|
import {
|
||||||
|
applyLocalCheckIn,
|
||||||
|
countPendingForSession,
|
||||||
|
getOfflineParticipant,
|
||||||
|
getOfflineSession,
|
||||||
|
insertOutbox,
|
||||||
|
isOfflinePackActive,
|
||||||
|
updateParticipantFromSync,
|
||||||
|
} from './repos';
|
||||||
|
|
||||||
|
export type CheckInServiceResult =
|
||||||
|
| { ok: true; result: AttendanceCheckInResult; pendingSync: boolean }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
async function isOnline(): Promise<boolean> {
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
return Boolean(state.isConnected && state.isInternetReachable !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueLocalCheckIn(opts: {
|
||||||
|
eventId: string;
|
||||||
|
attendanceId: string;
|
||||||
|
attendanceName: string;
|
||||||
|
participantId: string;
|
||||||
|
}): Promise<CheckInServiceResult> {
|
||||||
|
const session = await getOfflineSession(opts.attendanceId);
|
||||||
|
if (!session) {
|
||||||
|
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
|
||||||
|
if (!participant) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Participant not in offline pack. Refresh offline data while online.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowDuplicate = session.allowDuplicateCheckIn === 1;
|
||||||
|
if (participant.alreadyCheckedIn === 1 && !allowDuplicate) {
|
||||||
|
return { ok: false, error: 'Participant already checked in' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedInAt = new Date().toISOString();
|
||||||
|
const clientOpId = uuidv4();
|
||||||
|
const deviceId = await getDeviceId();
|
||||||
|
|
||||||
|
await insertOutbox({
|
||||||
|
clientOpId,
|
||||||
|
eventId: opts.eventId,
|
||||||
|
attendanceId: opts.attendanceId,
|
||||||
|
participantId: opts.participantId,
|
||||||
|
checkedInAt,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt);
|
||||||
|
|
||||||
|
const nextCount = participant.checkInCount + 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
pendingSync: true,
|
||||||
|
result: {
|
||||||
|
attendanceId: opts.attendanceId,
|
||||||
|
attendanceName: opts.attendanceName || session.name,
|
||||||
|
participantId: opts.participantId,
|
||||||
|
displayName: participant.displayName,
|
||||||
|
email: participant.email || '',
|
||||||
|
phone: participant.phone || '',
|
||||||
|
checkInCount: nextCount,
|
||||||
|
lastCheckedInAt: checkedInAt,
|
||||||
|
allowDuplicateCheckIn: allowDuplicate,
|
||||||
|
alreadyCheckedIn: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check-in: live API when online with empty outbox; otherwise local outbox.
|
||||||
|
*/
|
||||||
|
export async function performAppCheckIn(opts: {
|
||||||
|
eventId: string;
|
||||||
|
attendanceId: string;
|
||||||
|
attendanceName: string;
|
||||||
|
participantId: string;
|
||||||
|
}): Promise<CheckInServiceResult> {
|
||||||
|
const packActive = await isOfflinePackActive(opts.eventId);
|
||||||
|
const pending = await countPendingForSession(opts.attendanceId);
|
||||||
|
const online = await isOnline();
|
||||||
|
|
||||||
|
if (packActive && (!online || pending > 0)) {
|
||||||
|
return queueLocalCheckIn(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!online) {
|
||||||
|
if (packActive) return queueLocalCheckIn(opts);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'You are offline. Enable Offline for this event first to check in without internet.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await attendancesApi.checkIn(opts.eventId, opts.attendanceId, opts.participantId);
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
// Network flake after we thought we were online — fall back to outbox if pack exists
|
||||||
|
if (res.status === 0 && packActive) {
|
||||||
|
return queueLocalCheckIn(opts);
|
||||||
|
}
|
||||||
|
return { ok: false, error: res.error || 'Check-in failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packActive) {
|
||||||
|
await updateParticipantFromSync(
|
||||||
|
opts.attendanceId,
|
||||||
|
opts.participantId,
|
||||||
|
res.data.checkInCount,
|
||||||
|
res.data.lastCheckedInAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, result: res.data, pendingSync: false };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { open, type DB } from '@op-engineering/op-sqlite';
|
||||||
|
|
||||||
|
let db: DB | null = null;
|
||||||
|
let readyPromise: Promise<DB> | null = null;
|
||||||
|
|
||||||
|
const SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS offline_events (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
location TEXT,
|
||||||
|
enabled_at TEXT NOT NULL,
|
||||||
|
last_synced_at TEXT,
|
||||||
|
snapshot_version INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS offline_sessions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sessions_event ON offline_sessions(event_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS offline_participants (
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
attendance_id TEXT NOT NULL,
|
||||||
|
participant_id TEXT NOT NULL,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
already_checked_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
check_in_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_checked_in_at TEXT,
|
||||||
|
PRIMARY KEY (attendance_id, participant_id),
|
||||||
|
FOREIGN KEY (attendance_id) REFERENCES offline_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_participants_event ON offline_participants(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_participants_name ON offline_participants(attendance_id, display_name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS outbox (
|
||||||
|
client_op_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
attendance_id TEXT NOT NULL,
|
||||||
|
participant_id TEXT NOT NULL,
|
||||||
|
checked_in_at TEXT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbox_event ON outbox(event_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbox_session ON outbox(attendance_id, status);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getDb(): Promise<DB> {
|
||||||
|
if (db) return db;
|
||||||
|
if (!readyPromise) {
|
||||||
|
readyPromise = (async () => {
|
||||||
|
const instance = open({ name: 'eventbuzz_checkins_offline.db' });
|
||||||
|
await instance.execute('PRAGMA foreign_keys = ON;');
|
||||||
|
await instance.executeBatch(
|
||||||
|
SCHEMA_SQL.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => [`${s};`] as [string]),
|
||||||
|
);
|
||||||
|
db = instance;
|
||||||
|
return instance;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return readyPromise;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const KEY = 'eventbuzz_checkins_device_id';
|
||||||
|
|
||||||
|
let cached: string | null = null;
|
||||||
|
|
||||||
|
export async function getDeviceId(): Promise<string> {
|
||||||
|
if (cached) return cached;
|
||||||
|
const existing = await AsyncStorage.getItem(KEY);
|
||||||
|
if (existing) {
|
||||||
|
cached = existing;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
await AsyncStorage.setItem(KEY, id);
|
||||||
|
cached = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { attendancesApi } from '../api/attendances';
|
||||||
|
import type { CheckinsEvent } from '../api/types';
|
||||||
|
import {
|
||||||
|
replaceParticipantsForSession,
|
||||||
|
replaceSessionsForEvent,
|
||||||
|
upsertOfflineEvent,
|
||||||
|
} from './repos';
|
||||||
|
|
||||||
|
export type DownloadProgress = {
|
||||||
|
phase: 'sessions' | 'participants' | 'done';
|
||||||
|
sessionIndex: number;
|
||||||
|
sessionCount: number;
|
||||||
|
sessionName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function downloadOfflinePack(
|
||||||
|
event: Pick<CheckinsEvent, '_id' | 'name' | 'startDate' | 'endDate' | 'location'>,
|
||||||
|
onProgress?: (p: DownloadProgress) => void,
|
||||||
|
): Promise<{ ok: true; sessionCount: number; participantCount: number } | { ok: false; error: string }> {
|
||||||
|
const sessionsRes = await attendancesApi.list(event._id);
|
||||||
|
if (sessionsRes.error || !sessionsRes.data) {
|
||||||
|
return { ok: false, error: sessionsRes.error || 'Failed to load attendance sessions' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (sessionsRes.data.attendances ?? []).filter((s) => s.status === 'active');
|
||||||
|
onProgress?.({ phase: 'sessions', sessionIndex: 0, sessionCount: active.length });
|
||||||
|
|
||||||
|
await upsertOfflineEvent({
|
||||||
|
_id: event._id,
|
||||||
|
name: event.name,
|
||||||
|
startDate: event.startDate,
|
||||||
|
endDate: event.endDate,
|
||||||
|
location: event.location,
|
||||||
|
attendanceSessionCount: active.length,
|
||||||
|
});
|
||||||
|
await replaceSessionsForEvent(event._id, active);
|
||||||
|
|
||||||
|
let participantCount = 0;
|
||||||
|
for (let i = 0; i < active.length; i++) {
|
||||||
|
const session = active[i];
|
||||||
|
onProgress?.({
|
||||||
|
phase: 'participants',
|
||||||
|
sessionIndex: i + 1,
|
||||||
|
sessionCount: active.length,
|
||||||
|
sessionName: session.name,
|
||||||
|
});
|
||||||
|
const partRes = await attendancesApi.searchParticipants(event._id, session._id);
|
||||||
|
if (partRes.error || !partRes.data) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: partRes.error || `Failed to download participants for ${session.name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const participants = partRes.data.participants ?? [];
|
||||||
|
participantCount += participants.length;
|
||||||
|
await replaceParticipantsForSession(event._id, session._id, participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({ phase: 'done', sessionIndex: active.length, sessionCount: active.length });
|
||||||
|
return { ok: true, sessionCount: active.length, participantCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-download snapshot without clearing pending outbox rows. */
|
||||||
|
export async function refreshOfflinePack(
|
||||||
|
event: Pick<CheckinsEvent, '_id' | 'name' | 'startDate' | 'endDate' | 'location'>,
|
||||||
|
onProgress?: (p: DownloadProgress) => void,
|
||||||
|
): Promise<{ ok: true; sessionCount: number; participantCount: number } | { ok: false; error: string }> {
|
||||||
|
return downloadOfflinePack(event, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSessionRoster(
|
||||||
|
eventId: string,
|
||||||
|
attendanceId: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const partRes = await attendancesApi.searchParticipants(eventId, attendanceId);
|
||||||
|
if (partRes.error || !partRes.data) {
|
||||||
|
return { ok: false, error: partRes.error || 'Failed to refresh roster' };
|
||||||
|
}
|
||||||
|
await replaceParticipantsForSession(eventId, attendanceId, partRes.data.participants ?? []);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
import type { AttendanceRecord, AttendanceSearchParticipant, CheckinsEvent } from '../api/types';
|
||||||
|
import { getDb } from './db';
|
||||||
|
import type {
|
||||||
|
EventOfflineStatus,
|
||||||
|
OfflineEventRow,
|
||||||
|
OfflineParticipantRow,
|
||||||
|
OfflineSessionRow,
|
||||||
|
OutboxRow,
|
||||||
|
OutboxStatus,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertOfflineEvent(event: CheckinsEvent): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
const enabledAt = nowIso();
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO offline_events (id, name, start_date, end_date, location, enabled_at, last_synced_at, snapshot_version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NULL, 1)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
start_date = excluded.start_date,
|
||||||
|
end_date = excluded.end_date,
|
||||||
|
location = excluded.location,
|
||||||
|
snapshot_version = offline_events.snapshot_version + 1`,
|
||||||
|
[
|
||||||
|
event._id,
|
||||||
|
event.name,
|
||||||
|
event.startDate ?? null,
|
||||||
|
event.endDate ?? null,
|
||||||
|
event.location ?? null,
|
||||||
|
enabledAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOfflineEvent(eventId: string): Promise<OfflineEventRow | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(`SELECT * FROM offline_events WHERE id = ?`, [eventId]);
|
||||||
|
const row = res.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name: String(row.name),
|
||||||
|
startDate: row.start_date != null ? String(row.start_date) : null,
|
||||||
|
endDate: row.end_date != null ? String(row.end_date) : null,
|
||||||
|
location: row.location != null ? String(row.location) : null,
|
||||||
|
enabledAt: String(row.enabled_at),
|
||||||
|
lastSyncedAt: row.last_synced_at != null ? String(row.last_synced_at) : null,
|
||||||
|
snapshotVersion: Number(row.snapshot_version) || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOfflineEventIds(): Promise<string[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(`SELECT id FROM offline_events`);
|
||||||
|
return res.rows.map((r) => String(r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOfflineEvents(): Promise<OfflineEventRow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(`SELECT * FROM offline_events ORDER BY name COLLATE NOCASE ASC`);
|
||||||
|
return res.rows.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
name: String(row.name),
|
||||||
|
startDate: row.start_date != null ? String(row.start_date) : null,
|
||||||
|
endDate: row.end_date != null ? String(row.end_date) : null,
|
||||||
|
location: row.location != null ? String(row.location) : null,
|
||||||
|
enabledAt: String(row.enabled_at),
|
||||||
|
lastSyncedAt: row.last_synced_at != null ? String(row.last_synced_at) : null,
|
||||||
|
snapshotVersion: Number(row.snapshot_version) || 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setEventLastSyncedAt(eventId: string, iso: string): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(`UPDATE offline_events SET last_synced_at = ? WHERE id = ?`, [iso, eventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceSessionsForEvent(
|
||||||
|
eventId: string,
|
||||||
|
sessions: AttendanceRecord[],
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
|
||||||
|
for (const s of sessions) {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
s._id,
|
||||||
|
eventId,
|
||||||
|
s.name,
|
||||||
|
s.status,
|
||||||
|
s.settings?.allowDuplicateCheckIn ? 1 : 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOfflineSessions(eventId: string): Promise<OfflineSessionRow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT * FROM offline_sessions WHERE event_id = ? AND status = 'active' ORDER BY name ASC`,
|
||||||
|
[eventId],
|
||||||
|
);
|
||||||
|
return res.rows.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
eventId: String(row.event_id),
|
||||||
|
name: String(row.name),
|
||||||
|
status: String(row.status),
|
||||||
|
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOfflineSession(attendanceId: string): Promise<OfflineSessionRow | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(`SELECT * FROM offline_sessions WHERE id = ?`, [attendanceId]);
|
||||||
|
const row = res.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
eventId: String(row.event_id),
|
||||||
|
name: String(row.name),
|
||||||
|
status: String(row.status),
|
||||||
|
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceParticipantsForSession(
|
||||||
|
eventId: string,
|
||||||
|
attendanceId: string,
|
||||||
|
participants: AttendanceSearchParticipant[],
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(`DELETE FROM offline_participants WHERE attendance_id = ?`, [attendanceId]);
|
||||||
|
|
||||||
|
const batch: [string, (string | number | null)[]][] = participants.map((p) => [
|
||||||
|
`INSERT INTO offline_participants (
|
||||||
|
event_id, attendance_id, participant_id, first_name, last_name, email, phone,
|
||||||
|
display_name, already_checked_in, check_in_count, last_checked_in_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
p._id,
|
||||||
|
p.firstName ?? null,
|
||||||
|
p.lastName ?? null,
|
||||||
|
p.email ?? null,
|
||||||
|
p.phone ?? null,
|
||||||
|
p.displayName,
|
||||||
|
p.alreadyCheckedIn ? 1 : 0,
|
||||||
|
p.checkInCount ?? 0,
|
||||||
|
p.lastCheckedInAt ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (batch.length) {
|
||||||
|
// Chunk to avoid huge single batches
|
||||||
|
const CHUNK = 200;
|
||||||
|
for (let i = 0; i < batch.length; i += CHUNK) {
|
||||||
|
await db.executeBatch(batch.slice(i, i + CHUNK) as [string, (string | number | null)[]][]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapParticipant(row: Record<string, unknown>): OfflineParticipantRow {
|
||||||
|
return {
|
||||||
|
eventId: String(row.event_id),
|
||||||
|
attendanceId: String(row.attendance_id),
|
||||||
|
participantId: String(row.participant_id),
|
||||||
|
firstName: row.first_name != null ? String(row.first_name) : null,
|
||||||
|
lastName: row.last_name != null ? String(row.last_name) : null,
|
||||||
|
email: row.email != null ? String(row.email) : null,
|
||||||
|
phone: row.phone != null ? String(row.phone) : null,
|
||||||
|
displayName: String(row.display_name),
|
||||||
|
alreadyCheckedIn: Number(row.already_checked_in) || 0,
|
||||||
|
checkInCount: Number(row.check_in_count) || 0,
|
||||||
|
lastCheckedInAt: row.last_checked_in_at != null ? String(row.last_checked_in_at) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOfflineParticipants(
|
||||||
|
attendanceId: string,
|
||||||
|
): Promise<AttendanceSearchParticipant[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT * FROM offline_participants WHERE attendance_id = ? ORDER BY display_name COLLATE NOCASE ASC`,
|
||||||
|
[attendanceId],
|
||||||
|
);
|
||||||
|
return res.rows.map((row) => {
|
||||||
|
const p = mapParticipant(row as Record<string, unknown>);
|
||||||
|
return {
|
||||||
|
_id: p.participantId,
|
||||||
|
firstName: p.firstName ?? undefined,
|
||||||
|
lastName: p.lastName ?? undefined,
|
||||||
|
email: p.email ?? undefined,
|
||||||
|
phone: p.phone ?? undefined,
|
||||||
|
displayName: p.displayName,
|
||||||
|
alreadyCheckedIn: p.alreadyCheckedIn === 1,
|
||||||
|
checkInCount: p.checkInCount,
|
||||||
|
lastCheckedInAt: p.lastCheckedInAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOfflineParticipant(
|
||||||
|
attendanceId: string,
|
||||||
|
participantId: string,
|
||||||
|
): Promise<OfflineParticipantRow | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT * FROM offline_participants WHERE attendance_id = ? AND participant_id = ?`,
|
||||||
|
[attendanceId, participantId],
|
||||||
|
);
|
||||||
|
const row = res.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return mapParticipant(row as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLocalCheckIn(
|
||||||
|
attendanceId: string,
|
||||||
|
participantId: string,
|
||||||
|
checkedInAt: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE offline_participants
|
||||||
|
SET already_checked_in = 1,
|
||||||
|
check_in_count = check_in_count + 1,
|
||||||
|
last_checked_in_at = ?
|
||||||
|
WHERE attendance_id = ? AND participant_id = ?`,
|
||||||
|
[checkedInAt, attendanceId, participantId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateParticipantFromSync(
|
||||||
|
attendanceId: string,
|
||||||
|
participantId: string,
|
||||||
|
checkInCount: number,
|
||||||
|
lastCheckedInAt: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE offline_participants
|
||||||
|
SET already_checked_in = ?,
|
||||||
|
check_in_count = ?,
|
||||||
|
last_checked_in_at = ?
|
||||||
|
WHERE attendance_id = ? AND participant_id = ?`,
|
||||||
|
[checkInCount > 0 ? 1 : 0, checkInCount, lastCheckedInAt, attendanceId, participantId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertOutbox(row: {
|
||||||
|
clientOpId: string;
|
||||||
|
eventId: string;
|
||||||
|
attendanceId: string;
|
||||||
|
participantId: string;
|
||||||
|
checkedInAt: string;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
const ts = nowIso();
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO outbox (
|
||||||
|
client_op_id, event_id, attendance_id, participant_id, checked_in_at,
|
||||||
|
device_id, status, last_error, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`,
|
||||||
|
[
|
||||||
|
row.clientOpId,
|
||||||
|
row.eventId,
|
||||||
|
row.attendanceId,
|
||||||
|
row.participantId,
|
||||||
|
row.checkedInAt,
|
||||||
|
row.deviceId,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOutbox(row: Record<string, unknown>): OutboxRow {
|
||||||
|
return {
|
||||||
|
clientOpId: String(row.client_op_id),
|
||||||
|
eventId: String(row.event_id),
|
||||||
|
attendanceId: String(row.attendance_id),
|
||||||
|
participantId: String(row.participant_id),
|
||||||
|
checkedInAt: String(row.checked_in_at),
|
||||||
|
deviceId: String(row.device_id),
|
||||||
|
status: String(row.status) as OutboxStatus,
|
||||||
|
lastError: row.last_error != null ? String(row.last_error) : null,
|
||||||
|
createdAt: String(row.created_at),
|
||||||
|
updatedAt: String(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingOutbox(eventId?: string): Promise<OutboxRow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = eventId
|
||||||
|
? await db.execute(
|
||||||
|
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
||||||
|
[eventId],
|
||||||
|
)
|
||||||
|
: await db.execute(
|
||||||
|
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
||||||
|
);
|
||||||
|
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT * FROM outbox WHERE event_id = ? AND status = 'conflict' ORDER BY updated_at DESC`,
|
||||||
|
[eventId],
|
||||||
|
);
|
||||||
|
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countOutboxByStatus(
|
||||||
|
eventId: string,
|
||||||
|
statuses: OutboxStatus[],
|
||||||
|
): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
const placeholders = statuses.map(() => '?').join(',');
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT COUNT(*) as c FROM outbox WHERE event_id = ? AND status IN (${placeholders})`,
|
||||||
|
[eventId, ...statuses],
|
||||||
|
);
|
||||||
|
return Number(res.rows[0]?.c) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countPendingForSession(attendanceId: string): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT COUNT(*) as c FROM outbox WHERE attendance_id = ? AND status IN ('pending', 'syncing')`,
|
||||||
|
[attendanceId],
|
||||||
|
);
|
||||||
|
return Number(res.rows[0]?.c) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOutboxStatus(
|
||||||
|
clientOpId: string,
|
||||||
|
status: OutboxStatus,
|
||||||
|
lastError?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE outbox SET status = ?, last_error = ?, updated_at = ? WHERE client_op_id = ?`,
|
||||||
|
[status, lastError ?? null, nowIso(), clientOpId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOutboxSyncing(clientOpIds: string[]): Promise<void> {
|
||||||
|
if (!clientOpIds.length) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const placeholders = clientOpIds.map(() => '?').join(',');
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE outbox SET status = 'syncing', updated_at = ? WHERE client_op_id IN (${placeholders})`,
|
||||||
|
[nowIso(), ...clientOpIds],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissConflict(clientOpId: string): Promise<void> {
|
||||||
|
// Keep as conflict but mark acknowledged by appending note; UI filters dismissed via status synced-from-conflict
|
||||||
|
await markOutboxStatus(clientOpId, 'synced', 'Dismissed by staff');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventOfflineStatus(eventId: string): Promise<EventOfflineStatus> {
|
||||||
|
const event = await getOfflineEvent(eventId);
|
||||||
|
if (!event) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
pendingCount: 0,
|
||||||
|
conflictCount: 0,
|
||||||
|
lastSyncedAt: null,
|
||||||
|
enabledAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [pendingCount, conflictCount] = await Promise.all([
|
||||||
|
countOutboxByStatus(eventId, ['pending', 'syncing']),
|
||||||
|
countOutboxByStatus(eventId, ['conflict']),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
pendingCount,
|
||||||
|
conflictCount,
|
||||||
|
lastSyncedAt: event.lastSyncedAt,
|
||||||
|
enabledAt: event.enabledAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeOfflineEventData(
|
||||||
|
eventId: string,
|
||||||
|
{ force = false }: { force?: boolean } = {},
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const pending = await countOutboxByStatus(eventId, ['pending', 'syncing']);
|
||||||
|
if (pending > 0 && !force) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `${pending} unsynced check-in${pending === 1 ? '' : 's'} remaining. Sync or confirm force remove.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(`DELETE FROM outbox WHERE event_id = ?`, [eventId]);
|
||||||
|
await db.execute(`DELETE FROM offline_participants WHERE event_id = ?`, [eventId]);
|
||||||
|
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
|
||||||
|
await db.execute(`DELETE FROM offline_events WHERE id = ?`, [eventId]);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isOfflinePackActive(eventId: string): Promise<boolean> {
|
||||||
|
const event = await getOfflineEvent(eventId);
|
||||||
|
return Boolean(event);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { attendancesApi } from '../api/attendances';
|
||||||
|
import { getDeviceId } from './deviceId';
|
||||||
|
import { refreshSessionRoster } from './download';
|
||||||
|
import {
|
||||||
|
listPendingOutbox,
|
||||||
|
markOutboxStatus,
|
||||||
|
markOutboxSyncing,
|
||||||
|
setEventLastSyncedAt,
|
||||||
|
updateParticipantFromSync,
|
||||||
|
} from './repos';
|
||||||
|
import type { SyncOpResult } from './types';
|
||||||
|
|
||||||
|
type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void;
|
||||||
|
|
||||||
|
let syncing = false;
|
||||||
|
const listeners = new Set<SyncListener>();
|
||||||
|
|
||||||
|
function emit(lastError: string | null = null) {
|
||||||
|
const snapshot = { syncing, lastError };
|
||||||
|
listeners.forEach((l) => l(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSyncEngine(listener: SyncListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener({ syncing, lastError: null });
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSyncEngineRunning(): boolean {
|
||||||
|
return syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushGroup(
|
||||||
|
eventId: string,
|
||||||
|
attendanceId: string,
|
||||||
|
ops: { clientOpId: string; participantId: string; checkedInAt: string }[],
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
await markOutboxSyncing(ops.map((o) => o.clientOpId));
|
||||||
|
const res = await attendancesApi.syncCheckIns(eventId, attendanceId, {
|
||||||
|
deviceId,
|
||||||
|
operations: ops,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
const err =
|
||||||
|
res.status === 404
|
||||||
|
? 'Sync API not found on this server. Deploy the backend offline-sync route, or point API_BASE_URL at a backend that includes POST .../check-ins/sync.'
|
||||||
|
: res.error || 'Sync failed';
|
||||||
|
for (const op of ops) {
|
||||||
|
await markOutboxStatus(op.clientOpId, 'pending', err);
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SyncOpResult[] = res.data.results || [];
|
||||||
|
const byId = new Map(results.map((r) => [r.clientOpId, r]));
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
const r = byId.get(op.clientOpId);
|
||||||
|
if (!r) {
|
||||||
|
await markOutboxStatus(op.clientOpId, 'pending', 'Missing result from server');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.status === 'applied' || r.status === 'duplicate') {
|
||||||
|
await markOutboxStatus(op.clientOpId, 'synced', r.message || null);
|
||||||
|
if (typeof r.checkInCount === 'number') {
|
||||||
|
await updateParticipantFromSync(
|
||||||
|
attendanceId,
|
||||||
|
op.participantId,
|
||||||
|
r.checkInCount,
|
||||||
|
r.lastCheckedInAt ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (r.status === 'conflict') {
|
||||||
|
await markOutboxStatus(op.clientOpId, 'conflict', r.message || 'Conflict');
|
||||||
|
if (typeof r.checkInCount === 'number') {
|
||||||
|
await updateParticipantFromSync(
|
||||||
|
attendanceId,
|
||||||
|
op.participantId,
|
||||||
|
r.checkInCount,
|
||||||
|
r.lastCheckedInAt ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await markOutboxStatus(op.clientOpId, 'rejected', r.message || 'Rejected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light roster refresh to pick up other devices
|
||||||
|
await refreshSessionRoster(eventId, attendanceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush pending outbox. Optionally scope to one event.
|
||||||
|
*/
|
||||||
|
export async function runSync(eventId?: string): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
flushed: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
if (syncing) {
|
||||||
|
return { ok: false, flushed: 0, error: 'Sync already in progress' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
if (!net.isConnected || net.isInternetReachable === false) {
|
||||||
|
return { ok: false, flushed: 0, error: 'No internet connection' };
|
||||||
|
}
|
||||||
|
|
||||||
|
syncing = true;
|
||||||
|
emit(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pending = await listPendingOutbox(eventId);
|
||||||
|
if (!pending.length) {
|
||||||
|
if (eventId) await setEventLastSyncedAt(eventId, new Date().toISOString());
|
||||||
|
return { ok: true, flushed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = await getDeviceId();
|
||||||
|
const groups = new Map<string, typeof pending>();
|
||||||
|
for (const row of pending) {
|
||||||
|
const key = `${row.eventId}::${row.attendanceId}`;
|
||||||
|
const list = groups.get(key) || [];
|
||||||
|
list.push(row);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushed = 0;
|
||||||
|
let lastError: string | null = null;
|
||||||
|
const touchedEvents = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, rows] of groups) {
|
||||||
|
const [eid, aid] = key.split('::');
|
||||||
|
touchedEvents.add(eid);
|
||||||
|
// Chunk 200
|
||||||
|
for (let i = 0; i < rows.length; i += 200) {
|
||||||
|
const chunk = rows.slice(i, i + 200);
|
||||||
|
const err = await flushGroup(
|
||||||
|
eid,
|
||||||
|
aid,
|
||||||
|
chunk.map((r) => ({
|
||||||
|
clientOpId: r.clientOpId,
|
||||||
|
participantId: r.participantId,
|
||||||
|
checkedInAt: r.checkedInAt,
|
||||||
|
})),
|
||||||
|
deviceId,
|
||||||
|
);
|
||||||
|
if (err) lastError = err;
|
||||||
|
else flushed += chunk.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
for (const eid of touchedEvents) {
|
||||||
|
await setEventLastSyncedAt(eid, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(lastError);
|
||||||
|
return lastError
|
||||||
|
? { ok: false, flushed, error: lastError }
|
||||||
|
: { ok: true, flushed };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Sync failed';
|
||||||
|
emit(msg);
|
||||||
|
return { ok: false, flushed: 0, error: msg };
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
emit(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -1,66 +1,214 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { attendancesApi } from '../api/attendances';
|
import { attendancesApi } from '../api/attendances';
|
||||||
import type { AttendanceRecord } from '../api/types';
|
import type { AttendanceRecord } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
import { Badge, Card, GradientHeader } from '../components/ui';
|
import {
|
||||||
|
dismissConflict,
|
||||||
|
getOfflineEvent,
|
||||||
|
listConflictOutbox,
|
||||||
|
listOfflineParticipants,
|
||||||
|
listOfflineSessions,
|
||||||
|
} from '../offline/repos';
|
||||||
|
import { colors, radii, shadows, spacing, typography } from '../theme';
|
||||||
|
import { AppIcon, type AppIconName, Card } from '../components/ui';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'AttendanceSessions'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'AttendanceSessions'>;
|
||||||
|
|
||||||
|
const SESSION_ICONS: AppIconName[] = ['business-outline', 'people-outline'];
|
||||||
|
const CHECKIN_GREEN = '#22C55E';
|
||||||
|
const PEOPLE_BLUE = '#60A5FA';
|
||||||
|
|
||||||
|
function SessionsHeader({
|
||||||
|
eventName,
|
||||||
|
onBack,
|
||||||
|
onSync,
|
||||||
|
syncing,
|
||||||
|
pendingCount,
|
||||||
|
isOnline,
|
||||||
|
offlineReady,
|
||||||
|
}: {
|
||||||
|
eventName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onSync: () => void;
|
||||||
|
syncing: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
isOnline: boolean;
|
||||||
|
offlineReady: boolean;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||||
|
<View style={styles.headerBg} />
|
||||||
|
<View style={styles.headerCircle} />
|
||||||
|
|
||||||
|
<View style={styles.headerTopRow}>
|
||||||
|
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
|
||||||
|
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
|
||||||
|
<Text style={styles.backLabel}>Events</Text>
|
||||||
|
</Pressable>
|
||||||
|
{offlineReady ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSync}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.syncBtn}
|
||||||
|
disabled={syncing || !isOnline}
|
||||||
|
>
|
||||||
|
{syncing ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<AppIcon name="cloud-upload-outline" size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
<Text style={styles.syncText}>
|
||||||
|
Sync{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={2}>
|
||||||
|
{eventName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
{offlineReady
|
||||||
|
? isOnline
|
||||||
|
? 'Offline pack ready · Select a session'
|
||||||
|
: 'Working offline · Select a session'
|
||||||
|
: 'Select an attendance session'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsListHeader({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Text style={styles.listHeader}>
|
||||||
|
{count} session{count === 1 ? '' : 's'} available
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
||||||
const { eventId, eventName } = route.params;
|
const { eventId, eventName } = route.params;
|
||||||
|
const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
|
||||||
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
|
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fromCache, setFromCache] = useState(false);
|
||||||
|
|
||||||
|
const status = statusByEvent[eventId];
|
||||||
|
const offlineReady = Boolean(status?.enabled);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (isOnline) {
|
||||||
const res = await attendancesApi.list(eventId);
|
const res = await attendancesApi.list(eventId);
|
||||||
if (res.error) {
|
if (!res.error && res.data) {
|
||||||
setError(res.error);
|
const active = (res.data.attendances ?? []).filter((s) => s.status === 'active');
|
||||||
setSessions([]);
|
|
||||||
} else {
|
|
||||||
const active = (res.data?.attendances ?? []).filter((s) => s.status === 'active');
|
|
||||||
setSessions(active);
|
setSessions(active);
|
||||||
|
setFromCache(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [eventId]);
|
// fall through to cache on failure
|
||||||
|
if (!(await getOfflineEvent(eventId))) {
|
||||||
|
setError(res.error || 'Failed to load sessions');
|
||||||
|
setSessions([]);
|
||||||
|
setFromCache(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = await listOfflineSessions(eventId);
|
||||||
|
if (!local.length) {
|
||||||
|
setError(
|
||||||
|
isOnline
|
||||||
|
? 'No active attendance sessions.'
|
||||||
|
: 'No offline data for this event. Go online and Enable Offline first.',
|
||||||
|
);
|
||||||
|
setSessions([]);
|
||||||
|
setFromCache(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: AttendanceRecord[] = [];
|
||||||
|
for (const s of local) {
|
||||||
|
const participants = await listOfflineParticipants(s.id);
|
||||||
|
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
|
||||||
|
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
|
||||||
|
mapped.push({
|
||||||
|
_id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status as 'active' | 'archived',
|
||||||
|
settings: { allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1 },
|
||||||
|
uniqueParticipants: unique,
|
||||||
|
totalCheckedIn: total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSessions(mapped);
|
||||||
|
setFromCache(true);
|
||||||
|
}, [eventId, isOnline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await load();
|
await load();
|
||||||
|
await refreshStatuses();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
}, [load]);
|
}, [load, refreshStatuses]);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
const result = await syncNow(eventId);
|
||||||
|
await load();
|
||||||
|
if (!result.ok && result.error) {
|
||||||
|
Alert.alert('Sync', result.error);
|
||||||
|
} else if (result.flushed > 0) {
|
||||||
|
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Sync', 'Nothing pending to sync.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = await listConflictOutbox(eventId);
|
||||||
|
if (conflicts.length) {
|
||||||
|
Alert.alert(
|
||||||
|
'Conflicts',
|
||||||
|
`${conflicts.length} check-in${conflicts.length === 1 ? '' : 's'} conflicted (already checked in elsewhere). Dismiss them?`,
|
||||||
|
[
|
||||||
|
{ text: 'Later', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Dismiss all',
|
||||||
|
onPress: async () => {
|
||||||
|
for (const c of conflicts) {
|
||||||
|
await dismissConflict(c.clientOpId);
|
||||||
|
}
|
||||||
|
await refreshStatuses();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item, index }: { item: AttendanceRecord; index: number }) => {
|
||||||
|
const checkIns = item.totalCheckedIn ?? 0;
|
||||||
|
const people = item.uniqueParticipants ?? 0;
|
||||||
|
const iconName = SESSION_ICONS[index % SESSION_ICONS.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap}>
|
|
||||||
<GradientHeader
|
|
||||||
title={eventName}
|
|
||||||
subtitle="Select an attendance session"
|
|
||||||
style={styles.header}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator style={styles.center} color={colors.primary} />
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={sessions}
|
|
||||||
keyExtractor={(item) => item._id}
|
|
||||||
contentContainerStyle={sessions.length === 0 ? styles.emptyList : styles.list}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<Text style={styles.empty}>{error || 'No active attendance sessions.'}</Text>
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.cardPress}
|
style={styles.cardPress}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
|
|
@ -73,53 +221,228 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card style={styles.card}>
|
<Card style={styles.card} padded>
|
||||||
<View style={styles.row}>
|
<View style={styles.cardRow}>
|
||||||
<View style={styles.rowText}>
|
<View style={styles.iconBox}>
|
||||||
|
<AppIcon name={iconName} size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardText}>
|
||||||
<Text style={styles.cardTitle}>{item.name}</Text>
|
<Text style={styles.cardTitle}>{item.name}</Text>
|
||||||
<Text style={styles.cardMeta}>
|
<View style={styles.statsRow}>
|
||||||
{item.totalCheckedIn ?? 0} check-in
|
<View style={styles.stat}>
|
||||||
{(item.totalCheckedIn ?? 0) === 1 ? '' : 's'} ·{' '}
|
<AppIcon name="checkmark-circle-outline" size={14} color={CHECKIN_GREEN} />
|
||||||
{item.uniqueParticipants ?? 0} people
|
<Text style={styles.statText}>
|
||||||
|
{checkIns} check-in{checkIns === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statDot}>·</Text>
|
||||||
|
<View style={styles.stat}>
|
||||||
|
<AppIcon name="people-outline" size={14} color={PEOPLE_BLUE} />
|
||||||
|
<Text style={styles.statText}>
|
||||||
|
{people} {people === 1 ? 'person' : 'people'}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.badges}>
|
|
||||||
<Badge>{item.uniqueParticipants ?? 0} attendees</Badge>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.chev}>
|
<View style={styles.chev}>
|
||||||
<Text style={styles.chevText}>›</Text>
|
<AppIcon name="chevron-forward-outline" size={16} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrap}>
|
||||||
|
<SessionsHeader
|
||||||
|
eventName={eventName}
|
||||||
|
onBack={() => navigation.goBack()}
|
||||||
|
onSync={() => { handleSync(); }}
|
||||||
|
syncing={syncing}
|
||||||
|
pendingCount={status?.pendingCount ?? 0}
|
||||||
|
isOnline={isOnline}
|
||||||
|
offlineReady={offlineReady}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fromCache ? (
|
||||||
|
<View style={styles.cacheBanner}>
|
||||||
|
<AppIcon name="cloud-offline-outline" size={14} color="#92400E" />
|
||||||
|
<Text style={styles.cacheBannerText}>Showing offline data</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator style={styles.center} color={colors.primary} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={sessions}
|
||||||
|
keyExtractor={(item) => item._id}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentInsetAdjustmentBehavior="never"
|
||||||
|
ListHeaderComponent={<SessionsListHeader count={sessions.length} />}
|
||||||
|
contentContainerStyle={sessions.length === 0 ? styles.emptyList : styles.list}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={styles.empty}>{error || 'No active attendance sessions.'}</Text>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardShadow = Platform.OS === 'ios' ? shadows.card.ios : shadows.card.android;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { flex: 1, backgroundColor: colors.background },
|
wrap: { flex: 1, backgroundColor: colors.background },
|
||||||
header: { paddingBottom: 26 },
|
header: {
|
||||||
list: { padding: spacing.lg, paddingTop: spacing.lg },
|
paddingHorizontal: spacing.xl,
|
||||||
emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
paddingBottom: spacing.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
headerBg: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
headerCircle: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
borderRadius: 64,
|
||||||
|
right: -30,
|
||||||
|
top: -30,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
headerTopRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
backLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
syncBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.18)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
},
|
||||||
|
syncText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
color: 'rgba(255,255,255,0.90)',
|
||||||
|
fontSize: 14,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
cacheBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: 'rgba(217,119,6,0.12)',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
cacheBannerText: {
|
||||||
|
color: '#92400E',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
listHeader: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.muted,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
emptyList: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
},
|
||||||
center: { marginTop: 32 },
|
center: { marginTop: 32 },
|
||||||
cardPress: { marginBottom: 12 },
|
cardPress: { marginBottom: 12 },
|
||||||
card: { borderRadius: radii.xl, borderWidth: 0 },
|
card: {
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
borderRadius: radii.xl,
|
||||||
rowText: { flex: 1 },
|
borderWidth: 0,
|
||||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '800', marginBottom: 6 },
|
...cardShadow,
|
||||||
cardMeta: { ...typography.small, color: colors.muted },
|
},
|
||||||
badges: { marginTop: 10 },
|
cardRow: {
|
||||||
chev: {
|
flexDirection: 'row',
|
||||||
width: 34,
|
alignItems: 'center',
|
||||||
height: 34,
|
gap: 14,
|
||||||
borderRadius: 17,
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(247,148,29,0.12)',
|
backgroundColor: 'rgba(247,148,29,0.14)',
|
||||||
|
},
|
||||||
|
cardText: { flex: 1, minWidth: 0 },
|
||||||
|
cardTitle: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
stat: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
statText: {
|
||||||
|
color: colors.muted,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
statDot: {
|
||||||
|
color: '#D1D5DB',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
chev: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(247,148,29,0.10)',
|
||||||
},
|
},
|
||||||
chevText: { color: colors.primaryDark, fontSize: 22, lineHeight: 22, fontWeight: '700' },
|
|
||||||
empty: { textAlign: 'center', color: colors.muted, fontSize: 15 },
|
empty: { textAlign: 'center', color: colors.muted, fontSize: 15 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
Image,
|
||||||
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { checkinsApi } from '../api/checkins';
|
import { checkinsApi } from '../api/checkins';
|
||||||
import type { CheckinsEvent } from '../api/types';
|
import type { CheckinsEvent } from '../api/types';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { downloadOfflinePack, refreshOfflinePack } from '../offline/download';
|
||||||
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
|
import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, shadows, spacing, typography } from '../theme';
|
||||||
import { AvatarInitials, Badge, Card, GradientHeader } from '../components/ui';
|
import { AppIcon, AvatarInitials, Card } from '../components/ui';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Events'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Events'>;
|
||||||
|
|
||||||
|
|
@ -25,23 +32,141 @@ function formatDate(iso?: string) {
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateRange(start?: string, end?: string) {
|
||||||
|
const startLabel = formatDate(start);
|
||||||
|
const endLabel = formatDate(end);
|
||||||
|
if (!startLabel) return '';
|
||||||
|
if (!endLabel || endLabel === startLabel) return startLabel;
|
||||||
|
return `${startLabel} – ${endLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventsHeader({
|
||||||
|
userEmail,
|
||||||
|
onLogout,
|
||||||
|
onSync,
|
||||||
|
syncing,
|
||||||
|
pendingTotal,
|
||||||
|
isOnline,
|
||||||
|
}: {
|
||||||
|
userEmail?: string | null;
|
||||||
|
onLogout: () => void;
|
||||||
|
onSync: () => void;
|
||||||
|
syncing: boolean;
|
||||||
|
pendingTotal: number;
|
||||||
|
isOnline: boolean;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||||
|
<View style={styles.headerBg} />
|
||||||
|
<View style={styles.headerCircleA} />
|
||||||
|
<View style={styles.headerCircleB} />
|
||||||
|
|
||||||
|
<View style={styles.headerTopRow}>
|
||||||
|
<Image
|
||||||
|
source={require('../assets/eventbuzz-logo-white.png')}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<View style={styles.headerActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onSync}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.syncBtn}
|
||||||
|
disabled={syncing || !isOnline}
|
||||||
|
>
|
||||||
|
{syncing ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<AppIcon name="cloud-upload-outline" size={18} color="rgba(255,255,255,0.9)" />
|
||||||
|
)}
|
||||||
|
<Text style={styles.syncText}>
|
||||||
|
Sync{pendingTotal > 0 ? ` (${pendingTotal})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={onLogout} hitSlop={10} style={styles.logoutBtn}>
|
||||||
|
<AppIcon name="log-out-outline" size={20} color="rgba(255,255,255,0.85)" />
|
||||||
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.userRow}>
|
||||||
|
<AvatarInitials email={userEmail} size={40} />
|
||||||
|
<View style={styles.userText}>
|
||||||
|
<Text style={styles.signedInLabel}>Signed in as</Text>
|
||||||
|
<Text style={styles.signedInEmail} numberOfLines={1}>
|
||||||
|
{userEmail || '—'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.onlinePill, !isOnline && styles.offlinePill]}>
|
||||||
|
<Text style={styles.onlinePillText}>{isOnline ? 'Online' : 'Offline'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventsListHeader({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.listHeader}>
|
||||||
|
<Text style={styles.listHeaderTitle}>Your Events</Text>
|
||||||
|
<Text style={styles.listHeaderCount}>
|
||||||
|
{count} event{count === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EventsScreen({ navigation }: Props) {
|
export function EventsScreen({ navigation }: Props) {
|
||||||
const { signOut, user } = useAuth();
|
const { signOut, user } = useAuth();
|
||||||
|
const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
|
||||||
const [events, setEvents] = useState<CheckinsEvent[]>([]);
|
const [events, setEvents] = useState<CheckinsEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busyEventId, setBusyEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (isOnline) {
|
||||||
const res = await checkinsApi.listEvents();
|
const res = await checkinsApi.listEvents();
|
||||||
if (res.error) {
|
if (!res.error && res.data) {
|
||||||
setError(res.error);
|
setEvents(res.data.events ?? []);
|
||||||
setEvents([]);
|
await refreshStatuses();
|
||||||
} else {
|
return;
|
||||||
setEvents(res.data?.events ?? []);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
// Offline / API failure: show locally downloaded events
|
||||||
|
const local = await listOfflineEvents();
|
||||||
|
if (!local.length) {
|
||||||
|
setEvents([]);
|
||||||
|
setError(
|
||||||
|
isOnline
|
||||||
|
? 'Failed to load events.'
|
||||||
|
: 'You are offline and have no downloaded events. Connect and Enable Offline first.',
|
||||||
|
);
|
||||||
|
await refreshStatuses();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mapped: CheckinsEvent[] = [];
|
||||||
|
for (const e of local) {
|
||||||
|
const sessions = await listOfflineSessions(e.id);
|
||||||
|
mapped.push({
|
||||||
|
_id: e.id,
|
||||||
|
name: e.name,
|
||||||
|
startDate: e.startDate ?? undefined,
|
||||||
|
endDate: e.endDate ?? undefined,
|
||||||
|
location: e.location ?? undefined,
|
||||||
|
attendanceSessionCount: sessions.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setEvents(mapped);
|
||||||
|
setError(null);
|
||||||
|
await refreshStatuses();
|
||||||
|
}, [refreshStatuses, isOnline]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -57,7 +182,117 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: CheckinsEvent }) => (
|
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 (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.cardPress}
|
style={styles.cardPress}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
|
|
@ -66,56 +301,95 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
eventName: item.name,
|
eventName: item.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onLongPress={() => openOfflineMenu(item)}
|
||||||
>
|
>
|
||||||
<Card style={styles.card} padded>
|
<Card style={styles.card} padded>
|
||||||
<View style={styles.cardRow}>
|
<View style={styles.cardRow}>
|
||||||
<View style={styles.cardText}>
|
<View style={styles.cardText}>
|
||||||
<Text style={styles.cardTitle}>{item.name}</Text>
|
<Text style={styles.cardTitle}>{item.name}</Text>
|
||||||
|
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={styles.cardMeta}>
|
<AppIcon name="calendar-outline" size={14} color={colors.primary} />
|
||||||
{formatDate(item.startDate)}
|
<Text style={styles.cardMeta}>{formatDateRange(item.startDate, item.endDate)}</Text>
|
||||||
{item.endDate && item.endDate !== item.startDate
|
</View>
|
||||||
? ` – ${formatDate(item.endDate)}`
|
|
||||||
: ''}
|
{item.location ? (
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<AppIcon name="location-outline" size={16} color={colors.primary} />
|
||||||
|
<Text style={[styles.cardMeta, styles.locationMeta]} numberOfLines={2}>
|
||||||
|
{item.location}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.location ? <Text style={styles.cardMeta}>{item.location}</Text> : null}
|
) : null}
|
||||||
<View style={styles.badges}>
|
|
||||||
<Badge>
|
<View style={styles.badgeRow}>
|
||||||
{item.attendanceSessionCount} session
|
<View style={styles.sessionBadge}>
|
||||||
{item.attendanceSessionCount === 1 ? '' : 's'}
|
<AppIcon name="people-outline" size={13} color={colors.primaryDark} />
|
||||||
</Badge>
|
<Text style={styles.sessionBadgeText}>{sessionLabel}</Text>
|
||||||
|
</View>
|
||||||
|
{status?.enabled ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.offlineBadge,
|
||||||
|
status.pendingCount > 0 ? styles.offlineBadgePending : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name={status.pendingCount > 0 ? 'cloud-upload-outline' : 'cloud-done-outline'}
|
||||||
|
size={12}
|
||||||
|
color={status.pendingCount > 0 ? '#92400E' : '#166534'}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.offlineBadgeText,
|
||||||
|
status.pendingCount > 0 ? styles.offlineBadgeTextPending : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{status.pendingCount > 0
|
||||||
|
? `${status.pendingCount} pending`
|
||||||
|
: 'Offline ready'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardSide}>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.offlineAction}
|
||||||
|
disabled={busy}
|
||||||
|
onPress={() => openOfflineMenu(item)}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.primaryDark} />
|
||||||
|
) : (
|
||||||
|
<AppIcon
|
||||||
|
name={status?.enabled ? 'cloud-done-outline' : 'cloud-download-outline'}
|
||||||
|
size={18}
|
||||||
|
color={colors.primaryDark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
<View style={styles.chev}>
|
<View style={styles.chev}>
|
||||||
<Text style={styles.chevText}>›</Text>
|
<AppIcon name="chevron-forward-outline" size={16} color={colors.primaryDark} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap}>
|
<View style={styles.wrap}>
|
||||||
<GradientHeader
|
<EventsHeader
|
||||||
title="EventBuzz"
|
userEmail={user?.email}
|
||||||
subtitle={user?.email ? `Signed in as ${user.email}` : 'Signed in'}
|
onLogout={handleLogout}
|
||||||
right={
|
onSync={() => { handleSync(); }}
|
||||||
<Pressable onPress={signOut} hitSlop={10}>
|
syncing={syncing}
|
||||||
<Text style={styles.logout}>Logout</Text>
|
pendingTotal={pendingTotal}
|
||||||
</Pressable>
|
isOnline={isOnline}
|
||||||
}
|
|
||||||
bottom={
|
|
||||||
<View style={styles.headerBottom}>
|
|
||||||
<AvatarInitials email={user?.email} />
|
|
||||||
<View style={styles.headerBottomText}>
|
|
||||||
<Text style={styles.headerBottomLabel}>Your Events</Text>
|
|
||||||
<Text style={styles.headerBottomCount}>
|
|
||||||
{events.length} event{events.length === 1 ? '' : 's'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -125,14 +399,12 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
data={events}
|
data={events}
|
||||||
keyExtractor={(item) => item._id}
|
keyExtractor={(item) => item._id}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
contentInsetAdjustmentBehavior="never"
|
||||||
|
ListHeaderComponent={<EventsListHeader count={events.length} />}
|
||||||
contentContainerStyle={events.length === 0 ? styles.emptyList : styles.list}
|
contentContainerStyle={events.length === 0 ? styles.emptyList : styles.list}
|
||||||
refreshControl={
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
|
||||||
}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Text style={styles.empty}>
|
<Text style={styles.empty}>{error || 'No events assigned to your account.'}</Text>
|
||||||
{error || 'No events with attendance sessions.'}
|
|
||||||
</Text>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -140,38 +412,209 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardShadow = Platform.OS === 'ios' ? shadows.card.ios : shadows.card.android;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { flex: 1, backgroundColor: colors.background },
|
wrap: { flex: 1, backgroundColor: colors.background },
|
||||||
logout: { color: 'rgba(255,255,255,0.85)', ...typography.small, fontWeight: '600' },
|
header: {
|
||||||
headerBottom: {
|
paddingHorizontal: spacing.xl,
|
||||||
marginTop: 16,
|
paddingBottom: spacing.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
headerBg: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
headerCircleA: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
borderRadius: 64,
|
||||||
|
right: -30,
|
||||||
|
top: -30,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
headerCircleB: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 48,
|
||||||
|
left: '30%',
|
||||||
|
bottom: -20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
headerTopRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
headerActions: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||||||
|
logo: {
|
||||||
|
width: 60,
|
||||||
|
height: 31,
|
||||||
|
},
|
||||||
|
syncBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
syncText: {
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
...typography.small,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
logoutBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
logoutText: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
...typography.small,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
userRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
userText: { flex: 1, minWidth: 0 },
|
||||||
|
signedInLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
signedInEmail: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
onlinePill: {
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.25)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
},
|
||||||
|
offlinePill: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.25)',
|
||||||
|
},
|
||||||
|
onlinePillText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
listHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
listHeaderTitle: {
|
||||||
|
...typography.h2,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
listHeaderCount: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
emptyList: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
},
|
},
|
||||||
headerBottomText: { flex: 1 },
|
|
||||||
headerBottomLabel: { color: '#fff', ...typography.h2 },
|
|
||||||
headerBottomCount: { marginTop: 2, color: 'rgba(255,255,255,0.75)', ...typography.small },
|
|
||||||
|
|
||||||
list: { padding: spacing.lg, paddingTop: spacing.lg },
|
|
||||||
emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
|
||||||
center: { marginTop: 40 },
|
center: { marginTop: 40 },
|
||||||
cardPress: { marginBottom: 12 },
|
cardPress: { marginBottom: 12 },
|
||||||
card: { borderRadius: radii.xl, borderWidth: 0 },
|
card: {
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
borderWidth: 0,
|
||||||
|
...cardShadow,
|
||||||
|
},
|
||||||
cardRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
|
cardRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
|
||||||
cardText: { flex: 1, minWidth: 0 },
|
cardText: { flex: 1, minWidth: 0 },
|
||||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '800', marginBottom: 8 },
|
cardTitle: {
|
||||||
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
color: colors.text,
|
||||||
cardMeta: { color: colors.muted, fontSize: 12, marginBottom: 4 },
|
fontSize: 15,
|
||||||
badges: { marginTop: 10 },
|
fontWeight: '700',
|
||||||
chev: {
|
marginBottom: 10,
|
||||||
width: 34,
|
},
|
||||||
height: 34,
|
metaRow: {
|
||||||
borderRadius: 17,
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
cardMeta: { flex: 1, color: colors.muted, fontSize: 12, lineHeight: 18 },
|
||||||
|
locationMeta: { lineHeight: 18 },
|
||||||
|
badgeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
sessionBadge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
backgroundColor: 'rgba(247,148,29,0.10)',
|
||||||
|
},
|
||||||
|
sessionBadgeText: {
|
||||||
|
color: colors.primaryDark,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
offlineBadge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
backgroundColor: 'rgba(22,163,74,0.12)',
|
||||||
|
},
|
||||||
|
offlineBadgePending: {
|
||||||
|
backgroundColor: 'rgba(217,119,6,0.14)',
|
||||||
|
},
|
||||||
|
offlineBadgeText: {
|
||||||
|
color: '#166534',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
offlineBadgeTextPending: {
|
||||||
|
color: '#92400E',
|
||||||
|
},
|
||||||
|
cardSide: { alignItems: 'center', gap: 10 },
|
||||||
|
offlineAction: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(247,148,29,0.12)',
|
backgroundColor: 'rgba(247,148,29,0.10)',
|
||||||
|
},
|
||||||
|
chev: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(247,148,29,0.10)',
|
||||||
},
|
},
|
||||||
chevText: { color: colors.primaryDark, fontSize: 22, lineHeight: 22, fontWeight: '700' },
|
|
||||||
empty: { textAlign: 'center', color: colors.muted, fontSize: 15 },
|
empty: { textAlign: 'center', color: colors.muted, fontSize: 15 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
|
|
@ -42,17 +41,21 @@ export function Login2FAScreen({ navigation, route }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<View style={styles.wrap}>
|
||||||
style={styles.wrap}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
>
|
|
||||||
<View style={styles.inner}>
|
|
||||||
<GradientHeader
|
<GradientHeader
|
||||||
title="Two-factor"
|
title="Two-factor"
|
||||||
subtitle={`Enter the code for ${email}`}
|
subtitle={`Enter the code for ${email}`}
|
||||||
style={styles.hero}
|
style={styles.hero}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
contentInsetAdjustmentBehavior="never"
|
||||||
|
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
<Card style={styles.sheet} padded={false}>
|
<Card style={styles.sheet} padded={false}>
|
||||||
<View style={styles.sheetInner}>
|
<View style={styles.sheetInner}>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -79,21 +82,21 @@ export function Login2FAScreen({ navigation, route }: Props) {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { flex: 1, backgroundColor: colors.background },
|
wrap: { flex: 1, backgroundColor: colors.background },
|
||||||
inner: { flex: 1 },
|
|
||||||
hero: {
|
hero: {
|
||||||
paddingTop: 72,
|
paddingTop: 72,
|
||||||
paddingBottom: 44,
|
paddingBottom: 44,
|
||||||
paddingHorizontal: spacing.xl,
|
paddingHorizontal: spacing.xl,
|
||||||
},
|
},
|
||||||
|
scroll: { flex: 1 },
|
||||||
|
scrollContent: { flexGrow: 1 },
|
||||||
sheet: {
|
sheet: {
|
||||||
flex: 1,
|
|
||||||
marginTop: -28,
|
marginTop: -28,
|
||||||
borderTopLeftRadius: radii.xl,
|
borderTopLeftRadius: radii.xl,
|
||||||
borderTopRightRadius: radii.xl,
|
borderTopRightRadius: radii.xl,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
useWindowDimensions,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
@ -17,19 +16,21 @@ import { authApi } from '../api/auth';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, spacing, typography } from '../theme';
|
||||||
import { PrimaryButton, TextField } from '../components/ui';
|
import { AppIcon, PrimaryButton, TextField } from '../components/ui';
|
||||||
|
|
||||||
|
const FIELD_ICON_COLOR = '#9CA3AF';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Login'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Login'>;
|
||||||
|
|
||||||
const BRAND_ORANGE = '#F7941D';
|
const BRAND_ORANGE = '#F7941D';
|
||||||
const BRAND_ORANGE_DARK = '#E8780A';
|
const BRAND_ORANGE_DARK = '#E8780A';
|
||||||
const BUTTON_HEIGHT = 52;
|
/** Fixed height — do not tie to window size (keyboard changes dimensions and causes flicker). */
|
||||||
const BOTTOM_BAR_PAD_Y = 16;
|
const HERO_BODY_HEIGHT = 240;
|
||||||
|
|
||||||
export function LoginScreen({ navigation }: Props) {
|
export function LoginScreen({ navigation }: Props) {
|
||||||
const { refreshUser } = useAuth();
|
const { refreshUser } = useAuth();
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const heroHeight = Math.round(windowHeight * 0.42);
|
const passwordRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
@ -74,12 +75,15 @@ export function LoginScreen({ navigation }: Props) {
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<StatusBar barStyle="light-content" backgroundColor={BRAND_ORANGE} />
|
<StatusBar barStyle="light-content" backgroundColor={BRAND_ORANGE} />
|
||||||
|
|
||||||
{/* Orange hero — fixed pixel height so it always renders */}
|
<View
|
||||||
<View style={[styles.hero, { height: heroHeight + insets.top, paddingTop: insets.top + 24 }]}>
|
style={[
|
||||||
|
styles.hero,
|
||||||
|
{ height: HERO_BODY_HEIGHT + insets.top, paddingTop: insets.top + 20 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.heroGradientDark} />
|
<View style={styles.heroGradientDark} />
|
||||||
<View style={styles.heroCircleA} />
|
<View style={styles.heroCircleA} />
|
||||||
<View style={styles.heroCircleB} />
|
<View style={styles.heroCircleB} />
|
||||||
|
|
||||||
<View style={styles.logoCard}>
|
<View style={styles.logoCard}>
|
||||||
<Image
|
<Image
|
||||||
source={require('../assets/eventbuzz-logo.png')}
|
source={require('../assets/eventbuzz-logo.png')}
|
||||||
|
|
@ -90,24 +94,17 @@ export function LoginScreen({ navigation }: Props) {
|
||||||
<Text style={styles.heroSubtitle}>Attendance Management</Text>
|
<Text style={styles.heroSubtitle}>Attendance Management</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={styles.formArea}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
>
|
|
||||||
<View style={styles.sheet}>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
style={styles.formArea}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentContainerStyle={[
|
keyboardDismissMode="on-drag"
|
||||||
styles.sheetScroll,
|
contentInsetAdjustmentBehavior="never"
|
||||||
{
|
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||||
paddingBottom:
|
|
||||||
BUTTON_HEIGHT +
|
|
||||||
BOTTOM_BAR_PAD_Y * 2 +
|
|
||||||
Math.max(insets.bottom, 16) +
|
|
||||||
18,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.sheetScrollContent,
|
||||||
|
{ paddingBottom: insets.bottom + spacing.xl },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Welcome back</Text>
|
<Text style={styles.title}>Welcome back</Text>
|
||||||
<Text style={styles.sub}>Sign in to your dashboard account</Text>
|
<Text style={styles.sub}>Sign in to your dashboard account</Text>
|
||||||
|
|
@ -126,36 +123,42 @@ export function LoginScreen({ navigation }: Props) {
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
left={<Text style={styles.fieldIcon}>✉</Text>}
|
left={<AppIcon name="mail-outline" size={18} color={FIELD_ICON_COLOR} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
ref={passwordRef}
|
||||||
label="Password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
secureTextEntry={!showPw}
|
secureTextEntry={!showPw}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={submit}
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
left={<Text style={styles.fieldIcon}>🔒</Text>}
|
left={<AppIcon name="lock-closed-outline" size={18} color={FIELD_ICON_COLOR} />}
|
||||||
right={
|
right={
|
||||||
<Pressable onPress={() => setShowPw((v) => !v)} hitSlop={10}>
|
<Pressable onPress={() => setShowPw((v) => !v)} hitSlop={10}>
|
||||||
<Text style={styles.fieldIcon}>{showPw ? '🙈' : '👁'}</Text>
|
<AppIcon
|
||||||
|
name={showPw ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
color={FIELD_ICON_COLOR}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title={loading ? 'Signing in…' : 'Sign In'}
|
title={loading ? 'Signing in…' : 'Sign In'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
style={styles.signInBtn}
|
style={styles.signInBtn}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ScrollView>
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +172,7 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: BRAND_ORANGE,
|
backgroundColor: BRAND_ORANGE,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
paddingBottom: 36,
|
paddingBottom: 28,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
heroGradientDark: {
|
heroGradientDark: {
|
||||||
|
|
@ -201,7 +204,7 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: 'rgba(255,255,255,0.10)',
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
},
|
},
|
||||||
logoCard: {
|
logoCard: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
|
|
@ -213,38 +216,30 @@ const styles = StyleSheet.create({
|
||||||
shadowRadius: 20,
|
shadowRadius: 20,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
},
|
},
|
||||||
logo: { width: 144, height: 52 },
|
logo: { width: 140, height: 73 },
|
||||||
heroSubtitle: {
|
heroSubtitle: {
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: 'rgba(255,255,255,0.92)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
marginBottom: 10
|
||||||
},
|
},
|
||||||
formArea: {
|
formArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
marginTop: -24,
|
||||||
sheet: {
|
|
||||||
flex: 1,
|
|
||||||
marginTop: -28,
|
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderTopLeftRadius: 28,
|
borderTopLeftRadius: 28,
|
||||||
borderTopRightRadius: 28,
|
borderTopRightRadius: 28,
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: -4 },
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 24,
|
|
||||||
elevation: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
},
|
||||||
sheetScroll: {
|
sheetScrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
paddingHorizontal: spacing.xl,
|
paddingHorizontal: spacing.xl,
|
||||||
paddingTop: 32,
|
paddingTop: 28,
|
||||||
paddingBottom: 16,
|
|
||||||
},
|
},
|
||||||
title: { ...typography.title, color: colors.text, marginBottom: 6 },
|
title: { ...typography.title, color: colors.text, marginBottom: 6 },
|
||||||
sub: { ...typography.body, color: colors.muted, marginBottom: 24 },
|
sub: { ...typography.body, color: colors.muted, marginBottom: 24 },
|
||||||
fields: { gap: 16 },
|
fields: { gap: 16 },
|
||||||
fieldIcon: { fontSize: 16, color: '#9CA3AF' },
|
|
||||||
errorBox: {
|
errorBox: {
|
||||||
backgroundColor: 'rgba(220,38,38,0.08)',
|
backgroundColor: 'rgba(220,38,38,0.08)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|
@ -256,20 +251,6 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
errorText: { ...typography.body, color: colors.error },
|
errorText: { ...typography.body, color: colors.error },
|
||||||
signInBtn: {
|
signInBtn: {
|
||||||
borderRadius: 14,
|
marginTop: spacing.lg,
|
||||||
shadowColor: BRAND_ORANGE,
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.35,
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 6,
|
|
||||||
},
|
|
||||||
bottomBar: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
paddingHorizontal: spacing.xl,
|
|
||||||
paddingTop: BOTTOM_BAR_PAD_Y,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,31 @@ import {
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { attendancesApi } from '../api/attendances';
|
import { attendancesApi } from '../api/attendances';
|
||||||
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
|
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
|
||||||
import { CheckInResultModal } from '../components/CheckInResultModal';
|
import { CheckInResultModal } from '../components/CheckInResultModal';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
import { performAppCheckIn } from '../offline/checkInService';
|
||||||
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
|
import {
|
||||||
|
countPendingForSession,
|
||||||
|
isOfflinePackActive,
|
||||||
|
listOfflineParticipants,
|
||||||
|
} from '../offline/repos';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, spacing, typography } from '../theme';
|
||||||
import { Card, GradientHeader, PrimaryButton, TextField } from '../components/ui';
|
import { AppIcon, Card } from '../components/ui';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Participants'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Participants'>;
|
||||||
|
|
||||||
|
const CHECKIN_GREEN = '#22C55E';
|
||||||
|
const AGAIN_BLUE = '#2563EB';
|
||||||
|
const NAVY_SCAN = '#1A1A2E';
|
||||||
|
|
||||||
function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean {
|
function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean {
|
||||||
const needle = q.trim().toLowerCase();
|
const needle = q.trim().toLowerCase();
|
||||||
if (!needle) return true;
|
if (!needle) return true;
|
||||||
|
|
@ -27,8 +40,96 @@ function matchesParticipant(p: AttendanceSearchParticipant, q: string): boolean
|
||||||
return hay.includes(needle);
|
return hay.includes(needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitial(name: string) {
|
||||||
|
return (name.trim()[0] || '?').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheckInTime(iso?: string | null) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticipantsHeader({
|
||||||
|
eventName,
|
||||||
|
sessionName,
|
||||||
|
checkedInCount,
|
||||||
|
totalCount,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
eventName: string;
|
||||||
|
sessionName: string;
|
||||||
|
checkedInCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||||
|
<View style={styles.headerBg} />
|
||||||
|
<View style={styles.headerCircle} />
|
||||||
|
|
||||||
|
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
|
||||||
|
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
|
||||||
|
<Text style={styles.backLabel} numberOfLines={1}>
|
||||||
|
{eventName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.headerMain}>
|
||||||
|
<View style={styles.headerTextCol}>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={2}>
|
||||||
|
{sessionName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
Attendance · {checkedInCount}/{totalCount} checked in
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.checkedBadge}>
|
||||||
|
<Text style={styles.checkedBadgeCount}>{checkedInCount}</Text>
|
||||||
|
<Text style={styles.checkedBadgeLabel}>checked in</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchBar({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.searchStrip}>
|
||||||
|
<View style={styles.searchBox}>
|
||||||
|
<AppIcon name="search-outline" size={16} color="#9CA3AF" />
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder="Filter by name, email, phone..."
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
style={styles.searchInput}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{value.length > 0 ? (
|
||||||
|
<Pressable onPress={() => onChangeText('')} hitSlop={8}>
|
||||||
|
<AppIcon name="close-circle-outline" size={16} color="#9CA3AF" />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ParticipantsScreen({ navigation, route }: Props) {
|
export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
const { eventId, attendanceId, attendanceName, allowDuplicate } = route.params;
|
const { eventId, eventName, attendanceId, attendanceName, allowDuplicate } = route.params;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isOnline, refreshStatuses } = useOffline();
|
||||||
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
|
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
|
||||||
const [searchQ, setSearchQ] = useState('');
|
const [searchQ, setSearchQ] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -36,18 +137,46 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
|
const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
|
||||||
const [resultError, setResultError] = useState<string | null>(null);
|
const [resultError, setResultError] = useState<string | null>(null);
|
||||||
|
const [pendingSync, setPendingSync] = useState(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [fromCache, setFromCache] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await attendancesApi.searchParticipants(eventId, attendanceId);
|
const packActive = await isOfflinePackActive(eventId);
|
||||||
if (res.error) {
|
const pending = packActive ? await countPendingForSession(attendanceId) : 0;
|
||||||
setError(res.error);
|
|
||||||
setAll([]);
|
// Prefer local roster when offline, or when unsynced outbox would diverge from server.
|
||||||
} else {
|
if (packActive && (!isOnline || pending > 0)) {
|
||||||
setAll(res.data?.participants ?? []);
|
const local = await listOfflineParticipants(attendanceId);
|
||||||
|
setAll(local);
|
||||||
|
setFromCache(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [eventId, attendanceId]);
|
|
||||||
|
if (isOnline) {
|
||||||
|
const res = await attendancesApi.searchParticipants(eventId, attendanceId);
|
||||||
|
if (!res.error && res.data) {
|
||||||
|
setAll(res.data.participants ?? []);
|
||||||
|
setFromCache(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (packActive) {
|
||||||
|
const local = await listOfflineParticipants(attendanceId);
|
||||||
|
setAll(local);
|
||||||
|
setFromCache(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(res.error || 'Failed to load participants');
|
||||||
|
setAll([]);
|
||||||
|
setFromCache(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('You are offline. Enable Offline for this event first.');
|
||||||
|
setAll([]);
|
||||||
|
setFromCache(false);
|
||||||
|
}, [eventId, attendanceId, isOnline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = navigation.addListener('focus', () => {
|
const unsub = navigation.addListener('focus', () => {
|
||||||
|
|
@ -66,96 +195,163 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
[all, searchQ],
|
[all, searchQ],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const checkedInCount = useMemo(
|
||||||
|
() => all.filter((p) => p.alreadyCheckedIn).length,
|
||||||
|
[all],
|
||||||
|
);
|
||||||
|
|
||||||
const checkIn = async (participantId: string) => {
|
const checkIn = async (participantId: string) => {
|
||||||
setBusyId(participantId);
|
setBusyId(participantId);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
const res = await attendancesApi.checkIn(eventId, attendanceId, participantId);
|
setPendingSync(false);
|
||||||
|
const outcome = await performAppCheckIn({
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
participantId,
|
||||||
|
});
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
if (res.error) {
|
if (!outcome.ok) {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(res.error);
|
setResultError(outcome.error);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResult(res.data ?? null);
|
setResult(outcome.result);
|
||||||
|
setPendingSync(outcome.pendingSync);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
await load();
|
await load();
|
||||||
|
await refreshStatuses();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
|
setPendingSync(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listBottomPad = Math.max(insets.bottom, 16) + 88;
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => {
|
||||||
|
const canCheckIn = !item.alreadyCheckedIn || allowDuplicate;
|
||||||
|
const isAgain = item.alreadyCheckedIn && allowDuplicate;
|
||||||
|
const showAction = canCheckIn;
|
||||||
|
const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—';
|
||||||
|
const checkInTime = formatCheckInTime(item.lastCheckedInAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={styles.rowCard} padded>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.avatar,
|
||||||
|
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.avatarText,
|
||||||
|
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{getInitial(item.displayName)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.rowText}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{item.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.meta} numberOfLines={1}>
|
||||||
|
{meta}
|
||||||
|
</Text>
|
||||||
|
{item.alreadyCheckedIn ? (
|
||||||
|
<View style={styles.checkedRow}>
|
||||||
|
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
|
||||||
|
<Text style={styles.checked}>
|
||||||
|
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showAction ? (
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.checkBtn,
|
||||||
|
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
|
||||||
|
]}
|
||||||
|
disabled={busyId === item._id}
|
||||||
|
onPress={() => checkIn(item._id)}
|
||||||
|
>
|
||||||
|
{busyId === item._id ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : isAgain ? (
|
||||||
|
<View style={styles.checkBtnInner}>
|
||||||
|
<AppIcon name="refresh-outline" size={11} color="#fff" />
|
||||||
|
<Text style={styles.checkBtnText}>Again</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.checkBtnText}>Check In</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap}>
|
<View style={styles.wrap}>
|
||||||
<GradientHeader
|
<ParticipantsHeader
|
||||||
title={attendanceName}
|
eventName={eventName}
|
||||||
subtitle={
|
sessionName={attendanceName}
|
||||||
searchQ.trim()
|
checkedInCount={checkedInCount}
|
||||||
? `${displayed.length} of ${all.length} shown`
|
totalCount={all.length}
|
||||||
: `${all.length} participant${all.length === 1 ? '' : 's'}`
|
onBack={() => navigation.goBack()}
|
||||||
}
|
|
||||||
style={styles.header}
|
|
||||||
bottom={
|
|
||||||
<TextField
|
|
||||||
value={searchQ}
|
|
||||||
onChangeText={setSearchQ}
|
|
||||||
placeholder="Filter by name, email, phone…"
|
|
||||||
containerStyle={styles.searchContainer}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SearchBar value={searchQ} onChangeText={setSearchQ} />
|
||||||
|
|
||||||
|
{fromCache ? (
|
||||||
|
<View style={styles.cacheBanner}>
|
||||||
|
<AppIcon name="cloud-offline-outline" size={14} color="#92400E" />
|
||||||
|
<Text style={styles.cacheBannerText}>Offline roster</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.countStrip}>
|
||||||
|
<Text style={styles.countText}>
|
||||||
|
{displayed.length} participant{displayed.length === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator style={styles.center} color={colors.primary} />
|
<ActivityIndicator style={styles.center} color={colors.primary} />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={displayed}
|
data={displayed}
|
||||||
keyExtractor={(item) => item._id}
|
keyExtractor={(item) => item._id}
|
||||||
contentContainerStyle={displayed.length === 0 ? styles.emptyList : styles.list}
|
renderItem={renderItem}
|
||||||
ListEmptyComponent={
|
contentInsetAdjustmentBehavior="never"
|
||||||
<Text style={styles.empty}>{error || 'No participants found.'}</Text>
|
contentContainerStyle={
|
||||||
|
displayed.length === 0
|
||||||
|
? [styles.emptyList, { paddingBottom: listBottomPad }]
|
||||||
|
: [styles.list, { paddingBottom: listBottomPad }]
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => {
|
ListEmptyComponent={
|
||||||
const canCheckIn = !item.alreadyCheckedIn || allowDuplicate;
|
<View style={styles.emptyWrap}>
|
||||||
return (
|
<AppIcon name="search-outline" size={32} color="#D1D5DB" />
|
||||||
<Card style={styles.rowCard} padded={false}>
|
<Text style={styles.empty}>{error || 'No participants found'}</Text>
|
||||||
<View style={styles.row}>
|
|
||||||
<View style={styles.rowText}>
|
|
||||||
<Text style={styles.name}>{item.displayName}</Text>
|
|
||||||
<Text style={styles.meta}>
|
|
||||||
{[item.email, item.phone].filter(Boolean).join(' · ') || '—'}
|
|
||||||
</Text>
|
|
||||||
{item.alreadyCheckedIn ? (
|
|
||||||
<Text style={styles.checked}>Already checked in</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
}
|
||||||
style={[styles.checkBtn, !canCheckIn && styles.checkBtnOff]}
|
|
||||||
disabled={!canCheckIn || busyId === item._id}
|
|
||||||
onPress={() => checkIn(item._id)}
|
|
||||||
>
|
|
||||||
{busyId === item._id ? (
|
|
||||||
<ActivityIndicator color="#fff" size="small" />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.checkBtnText}>
|
|
||||||
{item.alreadyCheckedIn && allowDuplicate ? 'Again' : 'Check in'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.bottomBar}>
|
<Pressable
|
||||||
<PrimaryButton
|
style={[styles.scanFab, { bottom: Math.max(insets.bottom, 16) + 8 }]}
|
||||||
title="Scan QR"
|
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate('QrScanner', {
|
navigation.navigate('QrScanner', {
|
||||||
eventId,
|
eventId,
|
||||||
|
|
@ -164,13 +360,16 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
allowDuplicate,
|
allowDuplicate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
</View>
|
<AppIcon name="qr-code-outline" size={18} color="#fff" />
|
||||||
|
<Text style={styles.scanFabText}>Scan QR</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
<CheckInResultModal
|
<CheckInResultModal
|
||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
result={result}
|
result={result}
|
||||||
error={resultError}
|
error={resultError}
|
||||||
|
pendingSync={pendingSync}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -179,32 +378,234 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { flex: 1, backgroundColor: colors.background },
|
wrap: { flex: 1, backgroundColor: colors.background },
|
||||||
header: { paddingBottom: 18 },
|
header: {
|
||||||
searchContainer: { marginTop: 14 },
|
paddingHorizontal: spacing.xl,
|
||||||
list: { paddingHorizontal: spacing.lg, paddingBottom: 120, paddingTop: spacing.lg },
|
paddingBottom: spacing.lg,
|
||||||
emptyList: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
overflow: 'hidden',
|
||||||
center: { marginTop: 32 },
|
|
||||||
rowCard: { marginBottom: 10, borderRadius: radii.xl, borderWidth: 0 },
|
|
||||||
row: { flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
|
||||||
rowText: { flex: 1, marginRight: 10 },
|
|
||||||
name: { color: colors.text, fontSize: 15, fontWeight: '800' },
|
|
||||||
meta: { ...typography.small, color: colors.muted, marginTop: 4 },
|
|
||||||
checked: { ...typography.small, color: colors.success, marginTop: 6, fontWeight: '700' },
|
|
||||||
checkBtn: {
|
|
||||||
backgroundColor: colors.primaryDark,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 11,
|
|
||||||
borderRadius: radii.md,
|
|
||||||
minWidth: 88,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
},
|
||||||
checkBtnOff: { backgroundColor: colors.disabled },
|
headerBg: {
|
||||||
checkBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
...StyleSheet.absoluteFill,
|
||||||
empty: { textAlign: 'center', color: colors.muted },
|
backgroundColor: colors.primary,
|
||||||
bottomBar: {
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
headerCircle: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
borderRadius: 64,
|
||||||
|
right: -30,
|
||||||
|
top: -30,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
maxWidth: '85%',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
backLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
headerMain: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
headerTextCol: { flex: 1, minWidth: 0 },
|
||||||
|
headerTitle: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
color: 'rgba(255,255,255,0.90)',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
checkedBadge: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.20)',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 64,
|
||||||
|
},
|
||||||
|
checkedBadgeCount: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
checkedBadgeLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.95)',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
cacheBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: 'rgba(217,119,6,0.12)',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
cacheBannerText: {
|
||||||
|
color: '#92400E',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
searchStrip: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
searchBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
paddingVertical: 0,
|
||||||
|
},
|
||||||
|
countStrip: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
},
|
||||||
|
emptyList: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
},
|
||||||
|
center: { marginTop: 32 },
|
||||||
|
rowCard: {
|
||||||
|
marginBottom: 10,
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avatarChecked: {
|
||||||
|
backgroundColor: CHECKIN_GREEN,
|
||||||
|
},
|
||||||
|
avatarPending: {
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
avatarTextChecked: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
avatarTextPending: {
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
rowText: { flex: 1, minWidth: 0, marginRight: 4 },
|
||||||
|
name: { color: colors.text, fontSize: 14, fontWeight: '600' },
|
||||||
|
meta: { ...typography.small, color: '#9CA3AF', marginTop: 2, fontSize: 12 },
|
||||||
|
checkedRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: CHECKIN_GREEN,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
checkBtn: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 9,
|
||||||
|
borderRadius: 12,
|
||||||
|
minWidth: 78,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
checkBtnPrimary: {
|
||||||
|
backgroundColor: colors.primaryDark,
|
||||||
|
},
|
||||||
|
checkBtnAgain: {
|
||||||
|
backgroundColor: AGAIN_BLUE,
|
||||||
|
},
|
||||||
|
checkBtnOff: {
|
||||||
|
backgroundColor: colors.disabled,
|
||||||
|
},
|
||||||
|
checkBtnInner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
checkBtnText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
emptyWrap: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
empty: { textAlign: 'center', color: colors.muted, fontSize: 14 },
|
||||||
|
scanFab: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: spacing.lg,
|
|
||||||
right: spacing.lg,
|
right: spacing.lg,
|
||||||
bottom: 18,
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: NAVY_SCAN,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 18,
|
||||||
|
shadowColor: NAVY_SCAN,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowOpacity: 0.45,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
scanFabText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import {
|
import {
|
||||||
Camera,
|
Camera,
|
||||||
|
|
@ -7,24 +15,112 @@ import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
useCodeScanner,
|
useCodeScanner,
|
||||||
} from 'react-native-vision-camera';
|
} from 'react-native-vision-camera';
|
||||||
import { attendancesApi } from '../api/attendances';
|
|
||||||
import type { AttendanceCheckInResult } from '../api/types';
|
import type { AttendanceCheckInResult } from '../api/types';
|
||||||
import { CheckInResultModal } from '../components/CheckInResultModal';
|
import { CheckInResultModal } from '../components/CheckInResultModal';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
import { performAppCheckIn } from '../offline/checkInService';
|
||||||
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
|
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, spacing, typography } from '../theme';
|
||||||
import { PrimaryButton } from '../components/ui';
|
import { PrimaryButton } from '../components/ui';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'QrScanner'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'QrScanner'>;
|
||||||
|
|
||||||
|
const DIM_OVERLAY = 'rgba(0,0,0,0.55)';
|
||||||
|
const CORNER_LEN = 28;
|
||||||
|
const CORNER_WIDTH = 4;
|
||||||
|
const SCAN_GAP_BELOW_HEADER = 28;
|
||||||
|
|
||||||
|
type CornerPosition = 'tl' | 'tr' | 'bl' | 'br';
|
||||||
|
|
||||||
|
function ScanCorner({ position }: { position: CornerPosition }) {
|
||||||
|
const cornerStyle =
|
||||||
|
position === 'tl'
|
||||||
|
? styles.cornerTL
|
||||||
|
: position === 'tr'
|
||||||
|
? styles.cornerTR
|
||||||
|
: position === 'bl'
|
||||||
|
? styles.cornerBL
|
||||||
|
: styles.cornerBR;
|
||||||
|
|
||||||
|
return <View style={[styles.corner, cornerStyle]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanFrameOverlay({
|
||||||
|
size,
|
||||||
|
scanTop,
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
}: {
|
||||||
|
size: number;
|
||||||
|
scanTop: number;
|
||||||
|
screenWidth: number;
|
||||||
|
screenHeight: number;
|
||||||
|
}) {
|
||||||
|
const scanLeft = Math.max(0, (screenWidth - size) / 2);
|
||||||
|
const scanBottom = scanTop + size;
|
||||||
|
const bottomHeight = Math.max(0, screenHeight - scanBottom);
|
||||||
|
const rightWidth = Math.max(0, screenWidth - scanLeft - size);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.scanOverlay, { width: screenWidth, height: screenHeight }]}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[styles.dimAbsolute, { top: 0, left: 0, width: screenWidth, height: scanTop }]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dimAbsolute,
|
||||||
|
{ top: scanBottom, left: 0, width: screenWidth, height: bottomHeight },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dimAbsolute,
|
||||||
|
{ top: scanTop, left: 0, width: scanLeft, height: size },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dimAbsolute,
|
||||||
|
{ top: scanTop, left: scanLeft + size, width: rightWidth, height: size },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.scanWindow,
|
||||||
|
{ top: scanTop, left: scanLeft, width: size, height: size },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ScanCorner position="tl" />
|
||||||
|
<ScanCorner position="tr" />
|
||||||
|
<ScanCorner position="bl" />
|
||||||
|
<ScanCorner position="br" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function QrScannerScreen({ navigation, route }: Props) {
|
export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: screenWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
const [layoutHeight, setLayoutHeight] = useState(0);
|
||||||
|
const screenHeight = layoutHeight || windowHeight;
|
||||||
|
const scanSize = Math.min(Math.round(screenWidth * 0.72), 280);
|
||||||
|
const headerTop = insets.top + spacing.md;
|
||||||
|
const [headerBottom, setHeaderBottom] = useState(headerTop + 96);
|
||||||
|
const scanTop = headerBottom + SCAN_GAP_BELOW_HEADER;
|
||||||
const { eventId, attendanceId, attendanceName } = route.params;
|
const { eventId, attendanceId, attendanceName } = route.params;
|
||||||
|
const { refreshStatuses } = useOffline();
|
||||||
const { hasPermission, requestPermission } = useCameraPermission();
|
const { hasPermission, requestPermission } = useCameraPermission();
|
||||||
const device = useCameraDevice('back');
|
const device = useCameraDevice('back');
|
||||||
const scannedRef = useRef(false);
|
const scannedRef = useRef(false);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
|
const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
|
||||||
const [resultError, setResultError] = useState<string | null>(null);
|
const [resultError, setResultError] = useState<string | null>(null);
|
||||||
|
const [pendingSync, setPendingSync] = useState(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
|
|
@ -35,6 +131,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
scannedRef.current = true;
|
scannedRef.current = true;
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(parsed.error);
|
setResultError(parsed.error);
|
||||||
|
setPendingSync(false);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -42,20 +139,29 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
scannedRef.current = true;
|
scannedRef.current = true;
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
|
setPendingSync(false);
|
||||||
|
|
||||||
const res = await attendancesApi.checkIn(eventId, attendanceId, parsed.participantId);
|
const outcome = await performAppCheckIn({
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
participantId: parsed.participantId,
|
||||||
|
});
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
|
|
||||||
if (res.error) {
|
if (!outcome.ok) {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(res.error);
|
setResultError(outcome.error);
|
||||||
|
setPendingSync(false);
|
||||||
} else {
|
} else {
|
||||||
setResult(res.data ?? null);
|
setResult(outcome.result);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
|
setPendingSync(outcome.pendingSync);
|
||||||
|
refreshStatuses().catch(() => undefined);
|
||||||
}
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
},
|
},
|
||||||
[eventId, attendanceId, processing],
|
[eventId, attendanceId, attendanceName, processing, refreshStatuses],
|
||||||
);
|
);
|
||||||
|
|
||||||
const codeScanner = useCodeScanner({
|
const codeScanner = useCodeScanner({
|
||||||
|
|
@ -70,6 +176,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
|
setPendingSync(false);
|
||||||
scannedRef.current = false;
|
scannedRef.current = false;
|
||||||
if (result && !resultError) {
|
if (result && !resultError) {
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
|
|
@ -102,15 +209,30 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap}>
|
<View
|
||||||
|
style={styles.wrap}
|
||||||
|
onLayout={(e) => setLayoutHeight(e.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
<Camera
|
<Camera
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
device={device}
|
device={device}
|
||||||
isActive={!modalVisible}
|
isActive={!modalVisible}
|
||||||
codeScanner={codeScanner}
|
codeScanner={codeScanner}
|
||||||
/>
|
/>
|
||||||
<View style={styles.overlay}>
|
<ScanFrameOverlay
|
||||||
<View style={styles.topPill}>
|
size={scanSize}
|
||||||
|
scanTop={scanTop}
|
||||||
|
screenWidth={screenWidth}
|
||||||
|
screenHeight={screenHeight}
|
||||||
|
/>
|
||||||
|
<View style={[styles.overlay, { top: headerTop }]}>
|
||||||
|
<View
|
||||||
|
style={styles.topPill}
|
||||||
|
onLayout={(e) => {
|
||||||
|
const { y, height } = e.nativeEvent.layout;
|
||||||
|
setHeaderBottom(headerTop + y + height);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text style={styles.title}>{attendanceName}</Text>
|
<Text style={styles.title}>{attendanceName}</Text>
|
||||||
<Text style={styles.hint}>Point at participant badge QR</Text>
|
<Text style={styles.hint}>Point at participant badge QR</Text>
|
||||||
{processing ? (
|
{processing ? (
|
||||||
|
|
@ -118,7 +240,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.bottom}>
|
<View style={[styles.bottom, { bottom: Math.max(insets.bottom, 22) }]}>
|
||||||
<Pressable style={styles.cancel} onPress={() => navigation.goBack()}>
|
<Pressable style={styles.cancel} onPress={() => navigation.goBack()}>
|
||||||
<Text style={styles.cancelText}>Cancel</Text>
|
<Text style={styles.cancelText}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -128,6 +250,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
result={result}
|
result={result}
|
||||||
error={resultError}
|
error={resultError}
|
||||||
|
pendingSync={pendingSync}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -136,12 +259,62 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { flex: 1, backgroundColor: '#000' },
|
wrap: { flex: 1, backgroundColor: '#000' },
|
||||||
|
scanOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
dimAbsolute: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: DIM_OVERLAY,
|
||||||
|
},
|
||||||
|
scanWindow: {
|
||||||
|
position: 'absolute',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.35)',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
corner: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: CORNER_LEN,
|
||||||
|
height: CORNER_LEN,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
cornerTL: {
|
||||||
|
top: -1,
|
||||||
|
left: -1,
|
||||||
|
borderTopWidth: CORNER_WIDTH,
|
||||||
|
borderLeftWidth: CORNER_WIDTH,
|
||||||
|
borderTopLeftRadius: 6,
|
||||||
|
},
|
||||||
|
cornerTR: {
|
||||||
|
top: -1,
|
||||||
|
right: -1,
|
||||||
|
borderTopWidth: CORNER_WIDTH,
|
||||||
|
borderRightWidth: CORNER_WIDTH,
|
||||||
|
borderTopRightRadius: 6,
|
||||||
|
},
|
||||||
|
cornerBL: {
|
||||||
|
bottom: -1,
|
||||||
|
left: -1,
|
||||||
|
borderBottomWidth: CORNER_WIDTH,
|
||||||
|
borderLeftWidth: CORNER_WIDTH,
|
||||||
|
borderBottomLeftRadius: 6,
|
||||||
|
},
|
||||||
|
cornerBR: {
|
||||||
|
bottom: -1,
|
||||||
|
right: -1,
|
||||||
|
borderBottomWidth: CORNER_WIDTH,
|
||||||
|
borderRightWidth: CORNER_WIDTH,
|
||||||
|
borderBottomRightRadius: 6,
|
||||||
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 48,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
topPill: {
|
topPill: {
|
||||||
width: '92%',
|
width: '92%',
|
||||||
|
|
@ -160,7 +333,7 @@ const styles = StyleSheet.create({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: spacing.lg,
|
left: spacing.lg,
|
||||||
right: spacing.lg,
|
right: spacing.lg,
|
||||||
bottom: 22,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue