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