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