diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1ddfb..0b03f47d6 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 41337b1e1..be53a7de1 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,17 +5,66 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - { "lastFilter": { @@ -54,22 +103,22 @@ - { + "keyToString": { + "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.cidr.known.project.marker": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.readMode.enableVisualFormatting": "true", + "ToolWindow.Device Manager 2.ShowToolbar": "false", + "cf.first.check.clang-format": "false", + "cidr.known.project.marker": "true", + "git-widget-placeholder": "main", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "E:/Scure shift(TP)", + "settings.editor.selected.configurable": "preferences.keymap" } -}]]> +} diff --git a/app-backend/.env b/app-backend/.env index 0709ce4f4..b26e0b0f3 100644 --- a/app-backend/.env +++ b/app-backend/.env @@ -1,3 +1,3 @@ -MONGO_URI=mongodb+srv://s223580955_db_user:Sanjolika%4020003@cluster0.tlvzspv.mongodb.net/ +MONGO_URI=mongodb+srv://95groot:1995Groot@secureshift-cluster.0zfeq5j.mongodb.net/ PORT=5000 JWT_SECRET=some_long_random_secret_here diff --git a/app-backend/src/controllers/user.controller.js b/app-backend/src/controllers/user.controller.js index e737c2edd..bc2847dfd 100644 --- a/app-backend/src/controllers/user.controller.js +++ b/app-backend/src/controllers/user.controller.js @@ -162,3 +162,46 @@ export const updateEmployerProfile = async (req, res) => { res.status(500).json({ message: err.message }); } }; + +/** + * @desc Register or update a push token for the logged-in user + * @route POST /api/v1/users/push-token + * @access Private (all roles) + */ +export const registerPushToken = async (req, res) => { + try { + const { token, platform, deviceId } = req.body; + + if (!token || typeof token !== 'string') { + return res.status(400).json({ message: 'Push token is required.' }); + } + + const user = await User.findById(req.user.id); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const existing = user.pushTokens?.find((item) => item.token === token); + if (existing) { + existing.platform = platform ?? existing.platform; + existing.deviceId = deviceId ?? existing.deviceId; + existing.updatedAt = new Date(); + } else { + user.pushTokens = [ + ...(user.pushTokens ?? []), + { + token, + platform, + deviceId, + updatedAt: new Date(), + }, + ]; + } + + await user.save(); + + return res.status(200).json({ message: 'Push token registered.' }); + } catch (err) { + return res.status(500).json({ message: err.message }); + } +}; diff --git a/app-backend/src/models/User.js b/app-backend/src/models/User.js index 86a7bdde9..b5d57f6fd 100644 --- a/app-backend/src/models/User.js +++ b/app-backend/src/models/User.js @@ -90,6 +90,15 @@ const userSchema = new mongoose.Schema( default: null, }, + pushTokens: [ + { + token: { type: String, required: true }, + platform: { type: String }, + deviceId: { type: String }, + updatedAt: { type: Date, default: Date.now }, + }, + ], + // soft delete fields isDeleted: { type: Boolean, default: false, index: true }, // marks user as deactivated deletedAt: { type: Date, default: null }, // when it was deactivated diff --git a/app-backend/src/routes/user.routes.js b/app-backend/src/routes/user.routes.js index b55aaf8bf..539a7d0dc 100644 --- a/app-backend/src/routes/user.routes.js +++ b/app-backend/src/routes/user.routes.js @@ -17,7 +17,8 @@ import { adminUpdateUserProfile, getAllGuards, listUsers, - deleteUser + deleteUser, + registerPushToken } from '../controllers/user.controller.js'; const router = express.Router(); @@ -80,6 +81,39 @@ router .get(auth, loadUser, getMyProfile) .put(auth, loadUser, updateMyProfile); +/** + * @swagger + * /api/v1/users/push-token: + * post: + * summary: Register a push notification token + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - token + * properties: + * token: + * type: string + * platform: + * type: string + * deviceId: + * type: string + * responses: + * 200: + * description: Token registered + * 400: + * description: Validation error + * 401: + * description: Unauthorized + */ +router.post('/push-token', auth, loadUser, registerPushToken); + /** * @swagger * /api/v1/users/profile: diff --git a/guard_app/App.tsx b/guard_app/App.tsx index 4ee48559d..6e8510428 100644 --- a/guard_app/App.tsx +++ b/guard_app/App.tsx @@ -1,10 +1,26 @@ // App.tsx import { NavigationContainer } from '@react-navigation/native'; -import React from 'react'; +import React, { useEffect } from 'react'; import AppNavigator from './src/navigation/AppNavigator'; +import { registerPushTokenIfNeeded, subscribeToPushTokenChanges } from './src/lib/pushNotifications'; export default function App() { + useEffect(() => { + let subscription: { remove: () => void } | null = null; + const register = async () => { + await registerPushTokenIfNeeded(); + subscription = subscribeToPushTokenChanges(async (newToken) => { + await registerPushTokenIfNeeded(newToken); + }); + }; + + void register(); + return () => { + subscription?.remove(); + }; + }, []); + return ( diff --git a/guard_app/app.json b/guard_app/app.json index 5a74f891a..d83e6d9bd 100644 --- a/guard_app/app.json +++ b/guard_app/app.json @@ -1,7 +1,7 @@ { "expo": { "name": "SecureShift-GuardApp", - "slug": "SecureShift-GuardApp", + "slug": "secureshift-guardapp", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", @@ -20,10 +20,17 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true + "edgeToEdgeEnabled": true, + "package": "com.secureshiftguardapp.secureshiftguardapp" }, "web": { "favicon": "./assets/favicon.png" - } + }, + "extra": { + "eas": { + "projectId": "59453bc8-475d-40dd-9f7f-f275fd6d1eea" + } + }, + "owner": "secureshift-guardapp" } } diff --git a/guard_app/package-lock.json b/guard_app/package-lock.json index a5ac095fd..1725b74ac 100644 --- a/guard_app/package-lock.json +++ b/guard_app/package-lock.json @@ -19,9 +19,12 @@ "date-fns": "^4.1.0", "expo": "~53.0.25", "expo-constants": "~17.1.8", + "expo-dev-client": "~5.2.4", + "expo-device": "~7.1.4", "expo-document-picker": "~13.1.6", "expo-image-picker": "~16.1.4", "expo-location": "~18.1.6", + "expo-notifications": "~0.31.5", "expo-status-bar": "~2.2.3", "react": "19.0.0", "react-dom": "19.0.0", @@ -2026,6 +2029,7 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-5.0.5.tgz", "integrity": "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A==", "license": "MIT", + "peer": true, "peerDependencies": { "react-native": "*" } @@ -2240,6 +2244,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4131,6 +4141,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4157,7 +4180,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4374,6 +4396,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4571,7 +4599,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4603,7 +4630,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5319,7 +5345,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5346,7 +5371,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6440,6 +6464,15 @@ } } }, + "node_modules/expo-application": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-6.1.5.tgz", + "integrity": "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "11.1.7", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz", @@ -6469,6 +6502,118 @@ "react-native": "*" } }, + "node_modules/expo-dev-client": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-5.2.4.tgz", + "integrity": "sha512-s/N/nK5LPo0QZJpV4aPijxyrzV4O49S3dN8D2fljqrX2WwFZzWwFO6dX1elPbTmddxumdcpczsdUPY+Ms8g43g==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "5.1.16", + "expo-dev-menu": "6.1.14", + "expo-dev-menu-interface": "1.10.0", + "expo-manifests": "~0.16.6", + "expo-updates-interface": "~1.1.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-5.1.16.tgz", + "integrity": "sha512-tbCske9pvbozaEblyxoyo/97D6od9Ma4yAuyUnXtRET1CKAPKYS+c4fiZ+I3B4qtpZwN3JNFUjG3oateN0y6Hg==", + "license": "MIT", + "dependencies": { + "ajv": "8.11.0", + "expo-dev-menu": "6.1.14", + "expo-manifests": "~0.16.6", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/expo-dev-launcher/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/expo-dev-menu": { + "version": "6.1.14", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-6.1.14.tgz", + "integrity": "sha512-yonNMg2GHJZtuisVowdl1iQjZfYP85r1D1IO+ar9D9zlrBPBJhq2XEju52jd1rDmDkmDuEhBSbPNhzIcsBNiPg==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "1.10.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz", + "integrity": "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-7.1.4.tgz", + "integrity": "sha512-HS04IiE1Fy0FRjBLurr9e5A6yj3kbmQB+2jCZvbSGpsjBnCLdSk/LCii4f5VFhPIBWJLyYuN5QqJyEAw6BcS4Q==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-document-picker": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-13.1.6.tgz", @@ -6523,6 +6668,12 @@ "expo": "*" } }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "license": "MIT" + }, "node_modules/expo-keep-awake": { "version": "14.1.4", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz", @@ -6542,6 +6693,19 @@ "expo": "*" } }, + "node_modules/expo-manifests": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.16.6.tgz", + "integrity": "sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w==", + "license": "MIT", + "dependencies": { + "@expo/config": "~11.0.12", + "expo-json-utils": "~0.15.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "2.1.14", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz", @@ -6569,6 +6733,26 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.31.5.tgz", + "integrity": "sha512-HsitfTrSESFDWwaX0Y+6GQlWEooQqZKdGbNTwTPHfp5PNCr02tVPwwya9j1tdg3Awj8/vmfXmSxzNhULfmgJhQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.7.6", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~6.1.5", + "expo-constants": "~17.1.8" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.2.3.tgz", @@ -6583,6 +6767,15 @@ "react-native": "*" } }, + "node_modules/expo-updates-interface": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz", + "integrity": "sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6823,7 +7016,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -7205,7 +7397,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7507,6 +7698,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7611,7 +7818,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7733,7 +7939,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -7774,6 +7979,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -7836,7 +8057,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7932,7 +8152,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -9808,11 +10027,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9822,7 +10056,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10391,7 +10624,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11443,7 +11675,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -11584,7 +11815,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -12958,7 +13188,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -12982,6 +13211,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -13179,7 +13421,6 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -13507,6 +13748,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/guard_app/package.json b/guard_app/package.json index 256a8ece0..34b6fb6ae 100644 --- a/guard_app/package.json +++ b/guard_app/package.json @@ -26,9 +26,12 @@ "date-fns": "^4.1.0", "expo": "~53.0.25", "expo-constants": "~17.1.8", + "expo-dev-client": "~5.2.4", + "expo-device": "~7.1.4", "expo-document-picker": "~13.1.6", "expo-image-picker": "~16.1.4", "expo-location": "~18.1.6", + "expo-notifications": "~0.31.5", "expo-status-bar": "~2.2.3", "react": "19.0.0", "react-dom": "19.0.0", diff --git a/guard_app/src/api/messages.ts b/guard_app/src/api/messages.ts new file mode 100644 index 000000000..990fadbf4 --- /dev/null +++ b/guard_app/src/api/messages.ts @@ -0,0 +1,83 @@ +import http from '../lib/http'; + +export type MessageUser = { + _id?: string; + id?: string; + name?: string; + email?: string; + role?: 'guard' | 'employer' | 'admin'; +}; + +export type MessageDto = { + _id?: string; + sender: MessageUser; + receiver: MessageUser; + content: string; + timestamp: string; + isRead: boolean; +}; + +type InboxResponse = { + data?: { + messages?: MessageDto[]; + }; + messages?: MessageDto[]; +}; + +type SentResponse = { + data?: { + messages?: MessageDto[]; + }; + messages?: MessageDto[]; +}; + +type ConversationResponse = { + data?: { + conversation?: { + participant?: { + id?: string; + _id?: string; + name?: string; + email?: string; + role?: 'guard' | 'employer' | 'admin'; + }; + messages?: MessageDto[]; + }; + }; +}; + +type SendMessageResponse = { + data?: { + messageId?: string; + sender?: MessageUser; + receiver?: MessageUser; + content?: string; + timestamp?: string; + isRead?: boolean; + }; +}; + +export async function getInboxMessages() { + const { data } = await http.get('/messages/inbox'); + return data?.data?.messages ?? data?.messages ?? []; +} + +export async function getSentMessages() { + const { data } = await http.get('/messages/sent'); + return data?.data?.messages ?? data?.messages ?? []; +} + +export async function getConversation(userId: string) { + const { data } = await http.get(`/messages/conversation/${userId}`); + return data?.data?.conversation ?? { participant: undefined, messages: [] }; +} + +export async function sendMessage(payload: { receiverId: string; content: string }) { + const { data } = await http.post('/messages', payload); + return data?.data ?? {}; +} + +export async function markMessageAsRead(messageId: string) { + const { data } = await http.patch(`/messages/${messageId}/read`); + return data; +} diff --git a/guard_app/src/api/pushTokens.ts b/guard_app/src/api/pushTokens.ts new file mode 100644 index 000000000..04e967c74 --- /dev/null +++ b/guard_app/src/api/pushTokens.ts @@ -0,0 +1,10 @@ +import http from '../lib/http'; + +export async function registerPushToken(payload: { + token: string; + platform: string; + deviceId?: string; +}) { + const { data } = await http.post('/users/push-token', payload); + return data; +} diff --git a/guard_app/src/lib/localStorage.ts b/guard_app/src/lib/localStorage.ts index 994b06e76..1ababec8a 100644 --- a/guard_app/src/lib/localStorage.ts +++ b/guard_app/src/lib/localStorage.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; const TOKEN_KEY = 'auth_token'; const PROFILE_IMAGE_KEY = 'profile_image'; +const PUSH_TOKEN_KEY = 'push_token'; export const LocalStorage = { setToken: async function (token: string): Promise { @@ -22,6 +23,15 @@ export const LocalStorage = { clearProfileImage: async function (): Promise { await AsyncStorage.removeItem(PROFILE_IMAGE_KEY); }, + setPushToken: async function (token: string): Promise { + await AsyncStorage.setItem(PUSH_TOKEN_KEY, token); + }, + getPushToken: async function (): Promise { + return AsyncStorage.getItem(PUSH_TOKEN_KEY); + }, + removePushToken: async function (): Promise { + await AsyncStorage.removeItem(PUSH_TOKEN_KEY); + }, clearAll: async function (): Promise { await AsyncStorage.clear(); }, diff --git a/guard_app/src/lib/pushNotifications.ts b/guard_app/src/lib/pushNotifications.ts new file mode 100644 index 000000000..75279cac6 --- /dev/null +++ b/guard_app/src/lib/pushNotifications.ts @@ -0,0 +1,58 @@ +import * as Notifications from 'expo-notifications'; +import Constants from 'expo-constants'; +import { Platform } from 'react-native'; + +import { registerPushToken } from '../api/pushTokens'; +import { LocalStorage } from './localStorage'; + +const getProjectId = () => + Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId; + +export async function registerPushTokenIfNeeded(tokenOverride?: string): Promise { + const authToken = await LocalStorage.getToken(); + if (!authToken) return; + + const storedToken = await LocalStorage.getPushToken(); + const newToken = tokenOverride ?? (await getExpoPushToken()); + if (!newToken) return; + + if (storedToken === newToken) return; + + await registerPushToken({ + token: newToken, + platform: Platform.OS, + }); + await LocalStorage.setPushToken(newToken); +} + +export async function getExpoPushToken(): Promise { + try { + const permission = await Notifications.getPermissionsAsync(); + let status = permission.status; + + if (status !== 'granted') { + const request = await Notifications.requestPermissionsAsync(); + status = request.status; + } + + if (status !== 'granted') { + return null; + } + + const token = await Notifications.getExpoPushTokenAsync({ + projectId: getProjectId(), + }); + return token.data; + } catch (error) { + console.warn('Failed to get push token', error); + return null; + } +} + +export function subscribeToPushTokenChanges(onToken: (token: string) => void) { + return Notifications.addPushTokenListener((event) => { + if (event?.data) { + onToken(event.data); + } + }); +} diff --git a/guard_app/src/navigation/AppNavigator.tsx b/guard_app/src/navigation/AppNavigator.tsx index 778c710cc..5889a58b2 100644 --- a/guard_app/src/navigation/AppNavigator.tsx +++ b/guard_app/src/navigation/AppNavigator.tsx @@ -2,6 +2,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import AppTabs from './AppTabs'; import CertificatesScreen from '../screen/CertificatesScreen'; +import DocumentsScreen from '../screen/DocumentsScreen'; import EditProfileScreen from '../screen/EditProfileScreen'; import LoginScreen from '../screen/loginscreen'; import MessagesScreen from '../screen/MessagesScreen'; @@ -15,9 +16,19 @@ export type RootStackParamList = { Splash: undefined; Login: undefined; Signup: undefined; + Documents: undefined; Settings: undefined; EditProfile: undefined; - Messages: undefined; + Messages: + | { + context?: 'shift' | 'general'; + shiftParticipantId?: string; + shiftParticipantName?: string; + shiftTitle?: string; + generalParticipantId?: string; + generalParticipantName?: string; + } + | undefined; Notifications: undefined; Certificates: undefined; @@ -58,6 +69,8 @@ export default function AppNavigator() { name="Certificates" component={CertificatesScreen} options={{ headerShown: true, title: 'Certificates' }} + /> + ([ - { - id: '1', - from: 'employer', - text: 'Hi Alex, can you confirm shift for tomorrow?', - time: '10:00 AM', - }, - { - id: '2', - from: 'guard', - text: 'Yes, I’ll be there at 9 AM sharp.', - time: '10:02 AM', - }, - { - id: '3', - from: 'employer', - text: 'Great, see you then!', - time: '10:05 AM', - }, - ]); + const route = useRoute>(); + const initialContext = + route.params?.context ?? (route.params?.shiftParticipantId ? 'shift' : 'general'); + const shiftTitle = route.params?.shiftTitle ?? 'Shift conversation'; + + const [messagesByContext, setMessagesByContext] = useState<{ + shift: Message[]; + general: Message[]; + }>({ shift: [], general: [] }); const [input, setInput] = useState(''); + const [activeContext, setActiveContext] = useState<'shift' | 'general'>(initialContext); + const [isTyping, setIsTyping] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentUser, setCurrentUser] = useState<{ id: string; name?: string; role?: string } | null>( + null, + ); + const [shiftParticipant, setShiftParticipant] = useState<{ + id: string; + name: string; + email?: string; + role?: string; + } | null>(null); + const [generalParticipant, setGeneralParticipant] = useState<{ + id: string; + name: string; + email?: string; + role?: string; + } | null>(null); + const [conversations, setConversations] = useState([]); + const [newRecipientId, setNewRecipientId] = useState(''); + const [newRecipientName, setNewRecipientName] = useState(''); + + const contextMessages = useMemo( + () => messagesByContext[activeContext], + [messagesByContext, activeContext], + ); + const activeParticipant = activeContext === 'shift' ? shiftParticipant : generalParticipant; + + const getUserId = (user?: MessageUser) => user?._id ?? user?.id; + + const mapDtoToMessage = (dto: MessageDto, context: 'shift' | 'general'): Message => { + const senderId = getUserId(dto.sender); + const isCurrentUser = senderId && senderId === currentUser?.id; + const inferredRole = + dto.sender?.role ?? (isCurrentUser ? currentUser?.role : undefined); + const role = inferredRole === 'employer' ? 'employer' : 'guard'; + return { + id: dto._id ?? `${dto.timestamp}-${senderId ?? 'unknown'}`, + from: role, + senderName: dto.sender?.name ?? dto.sender?.email ?? 'Unknown', + text: dto.content, + timestamp: dto.timestamp, + context, + shiftTitle: context === 'shift' ? shiftTitle : undefined, + status: isCurrentUser ? (dto.isRead ? 'read' : 'sent') : undefined, + }; + }; + + const buildParticipantFromMessage = (msg: MessageDto, meId: string) => { + const senderId = getUserId(msg.sender); + const receiverId = getUserId(msg.receiver); + const isSenderMe = senderId && senderId === meId; + const otherUser = isSenderMe ? msg.receiver : msg.sender; + const otherId = getUserId(otherUser); + if (!otherId) return null; + return { + id: otherId, + name: otherUser?.name ?? otherUser?.email ?? 'Participant', + email: otherUser?.email, + role: otherUser?.role, + }; + }; + + const buildConversationList = (inbox: MessageDto[], sent: MessageDto[], meId: string) => { + const byUser = new Map(); + const all = [...inbox, ...sent]; + all.forEach((msg) => { + const participant = buildParticipantFromMessage(msg, meId); + if (!participant) return; + const timestamp = new Date(msg.timestamp).getTime(); + const existing = byUser.get(participant.id); + const isUnread = msg.receiver && getUserId(msg.receiver) === meId && !msg.isRead; + const next = { + id: participant.id, + name: participant.name, + role: participant.role, + lastMessage: msg.content, + lastTimestamp: msg.timestamp, + unreadCount: (existing?.unreadCount ?? 0) + (isUnread ? 1 : 0), + lastTime: Math.max(existing?.lastTime ?? 0, timestamp), + }; + if (!existing || timestamp >= existing.lastTime) { + byUser.set(participant.id, next); + } else { + byUser.set(participant.id, { ...existing, unreadCount: next.unreadCount }); + } + }); + + return Array.from(byUser.values()) + .sort((a, b) => b.lastTime - a.lastTime) + .map(({ lastTime, ...rest }) => rest); + }; + + useEffect(() => { + const loadParticipants = async () => { + try { + setLoading(true); + setError(null); + + const me = await getMe(); + const meId = me?._id ?? me?.id; + if (!meId) throw new Error('Unable to determine user'); + setCurrentUser({ id: meId, name: me?.name, role: me?.role }); + + if (route.params?.shiftParticipantId) { + setShiftParticipant({ + id: route.params.shiftParticipantId, + name: route.params.shiftParticipantName ?? 'Shift participant', + }); + } - const sendMessage = () => { + if (route.params?.generalParticipantId) { + setGeneralParticipant({ + id: route.params.generalParticipantId, + name: route.params.generalParticipantName ?? 'Conversation', + }); + } + + const [inbox, sent] = await Promise.all([getInboxMessages(), getSentMessages()]); + const list = buildConversationList(inbox, sent, meId); + setConversations(list); + } catch (e) { + console.error(e); + setError('Failed to load messages'); + } finally { + setLoading(false); + } + }; + + void loadParticipants(); + }, [route.params?.generalParticipantId, route.params?.generalParticipantName, route.params?.shiftParticipantId, route.params?.shiftParticipantName]); + + useEffect(() => { + const loadConversation = async () => { + const participant = activeContext === 'shift' ? shiftParticipant : generalParticipant; + if (!participant?.id) { + setMessagesByContext((prev) => ({ ...prev, [activeContext]: [] })); + return; + } + + try { + setLoading(true); + setError(null); + const conversation = await getConversation(participant.id); + if (conversation?.participant) { + const { id, _id, name, email, role } = conversation.participant; + const normalizedParticipant = { + id: id ?? _id ?? participant.id, + name: name ?? email ?? participant.name, + email, + role, + }; + if (activeContext === 'shift') { + setShiftParticipant(normalizedParticipant); + } else { + setGeneralParticipant(normalizedParticipant); + } + } + const mapped = (conversation?.messages ?? []).map((msg) => mapDtoToMessage(msg, activeContext)); + setMessagesByContext((prev) => ({ ...prev, [activeContext]: mapped })); + } catch (e) { + console.error(e); + setError('Failed to load conversation'); + } finally { + setLoading(false); + } + }; + + void loadConversation(); + }, [activeContext, generalParticipant?.id, shiftParticipant?.id, currentUser?.id]); + + const formatTime = (iso: string) => + new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const sendMessage = async () => { if (!input.trim()) return; + const participant = activeContext === 'shift' ? shiftParticipant : generalParticipant; + if (!participant?.id) { + Alert.alert('No recipient', 'Select a conversation before sending.'); + return; + } + + const newId = Date.now().toString(); const newMsg: Message = { - id: Date.now().toString(), - from: 'guard', + id: newId, + from: currentUser?.role === 'employer' ? 'employer' : 'guard', + senderName: currentUser?.name ?? 'You', text: input.trim(), - time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + timestamp: new Date().toISOString(), + context: activeContext, + shiftTitle: activeContext === 'shift' ? shiftTitle : undefined, + status: 'sending', }; - setMessages((prev) => [...prev, newMsg]); + setMessagesByContext((prev) => ({ ...prev, [activeContext]: [...prev[activeContext], newMsg] })); setInput(''); + + try { + const sent = await sendMessageApi({ receiverId: participant.id, content: newMsg.text }); + setMessagesByContext((prev) => ({ + ...prev, + [activeContext]: prev[activeContext].map((msg) => + msg.id === newId + ? { + ...msg, + id: sent.messageId ?? msg.id, + timestamp: sent.timestamp ?? msg.timestamp, + status: sent.isRead ? 'read' : 'sent', + } + : msg, + ), + })); + } catch (e) { + console.error(e); + setMessagesByContext((prev) => ({ + ...prev, + [activeContext]: prev[activeContext].filter((msg) => msg.id !== newId), + })); + Alert.alert('Error', 'Failed to send message'); + } }; const renderMessage = ({ item }: { item: Message }) => ( - - {item.text} - {item.time} + + + + {item.senderName} • {item.from === 'guard' ? 'Guard' : 'Employer'} + + + {item.text} + + + + {formatTime(item.timestamp)} + + {item.from === 'guard' && item.status && ( + • {item.status} + )} + + ); + const renderConversation = ({ item }: { item: ConversationItem }) => ( + { + setGeneralParticipant({ + id: item.id, + name: item.name, + role: item.role, + }); + setActiveContext('general'); + }} + > + + + {item.name} + {formatTime(item.lastTimestamp)} + + + {item.lastMessage} + + + {item.unreadCount > 0 && ( + + {item.unreadCount} + + )} + + ); + + const handleStartConversation = () => { + const id = newRecipientId.trim(); + if (!id) { + Alert.alert('Missing info', 'Enter a recipient ID to start a conversation.'); + return; + } + setGeneralParticipant({ + id, + name: newRecipientName.trim() || 'Conversation', + }); + setActiveContext('general'); + setNewRecipientId(''); + setNewRecipientName(''); + }; + return ( - - Messages + + + Messages + + + setActiveContext('shift')} + > + + Shift + + + setActiveContext('general')} + > + + General + + + - item.id} - renderItem={renderMessage} - contentContainerStyle={styles.chat} - /> + + + {activeContext === 'shift' ? shiftTitle : 'General conversation'} + + + {activeParticipant?.name ? `With ${activeParticipant.name}` : 'No participant selected'} + + + + {error && {error}} + + {activeContext === 'general' && !activeParticipant?.id ? ( + + Conversations + + Start a new conversation + + + + Start + + + item.id} + renderItem={renderConversation} + contentContainerStyle={[ + styles.chat, + conversations.length === 0 && styles.chatEmpty, + ]} + ListEmptyComponent={ + loading ? ( + + + Loading conversations… + + ) : ( + + + No conversations yet + + Start a new chat from a shift or by selecting a contact. + + + ) + } + /> + + ) : ( + item.id} + renderItem={renderMessage} + contentContainerStyle={[ + styles.chat, + contextMessages.length === 0 && styles.chatEmpty, + ]} + ListEmptyComponent={ + loading ? ( + + + Loading messages… + + ) : ( + + + No messages yet + + Start the conversation to coordinate shifts or share updates. + + + ) + } + /> + )} + + {isTyping && ( + + + Employer is typing… + + setIsTyping(false)} + > + Dismiss + + + )} - + @@ -111,20 +533,120 @@ const styles = StyleSheet.create({ backgroundColor: NAVY, paddingVertical: 14, paddingHorizontal: 16, + justifyContent: 'space-between', }, + headerLeft: { flexDirection: 'row', alignItems: 'center' }, headerTitle: { color: '#fff', fontSize: 18, fontWeight: '700', marginLeft: 8, }, + contextToggle: { + flexDirection: 'row', + backgroundColor: 'rgba(255,255,255,0.18)', + borderRadius: 16, + padding: 2, + }, + contextChip: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 14, + }, + contextChipActive: { backgroundColor: '#ffffff' }, + contextChipText: { fontSize: 12, color: '#e5e7eb', fontWeight: '600' }, + contextChipTextActive: { color: NAVY }, + + contextBanner: { + backgroundColor: '#f1f5f9', + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + contextBannerText: { fontSize: 14, fontWeight: '700', color: SLATE }, + contextBannerSub: { fontSize: 12, color: '#6b7280', marginTop: 2 }, + errorText: { color: '#b91c1c', paddingHorizontal: 16, paddingTop: 8 }, chat: { padding: 12 }, + chatEmpty: { flexGrow: 1, justifyContent: 'center' }, + placeholder: { alignItems: 'center', paddingHorizontal: 24 }, + placeholderTitle: { marginTop: 8, fontSize: 16, fontWeight: '700', color: SLATE }, + placeholderText: { marginTop: 4, textAlign: 'center', color: '#6b7280' }, + + conversationListWrap: { flex: 1 }, + sectionTitle: { + fontSize: 14, + fontWeight: '700', + color: SLATE, + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 4, + }, + conversationRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + marginBottom: 10, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + conversationInfo: { flex: 1 }, + conversationHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + conversationName: { fontSize: 14, fontWeight: '700', color: SLATE }, + conversationTime: { fontSize: 11, color: '#6b7280' }, + conversationPreview: { fontSize: 12, color: '#6b7280' }, + unreadBadge: { + minWidth: 20, + paddingHorizontal: 6, + height: 20, + borderRadius: 10, + backgroundColor: NAVY, + alignItems: 'center', + justifyContent: 'center', + }, + unreadText: { color: '#ffffff', fontSize: 11, fontWeight: '700' }, + newConversationCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + marginHorizontal: 12, + marginBottom: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + newConversationTitle: { fontSize: 13, fontWeight: '700', color: SLATE, marginBottom: 8 }, + newConversationInput: { + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + marginBottom: 8, + fontSize: 13, + color: SLATE, + backgroundColor: '#F9FAFB', + }, + newConversationBtn: { + alignSelf: 'flex-start', + backgroundColor: NAVY, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + }, + newConversationBtnText: { color: '#ffffff', fontWeight: '700', fontSize: 13 }, + messageRow: { marginBottom: 10 }, bubble: { maxWidth: '75%', padding: 12, - marginBottom: 10, borderRadius: 16, }, bubbleGuard: { @@ -137,8 +659,29 @@ const styles = StyleSheet.create({ backgroundColor: '#e5e7eb', borderTopLeftRadius: 4, }, - msgText: { color: '#fff', fontSize: 15 }, - msgTime: { fontSize: 10, color: '#d1d5db', marginTop: 4, textAlign: 'right' }, + msgSender: { fontSize: 11, color: '#6b7280', marginBottom: 4, fontWeight: '600' }, + msgTextLight: { color: '#fff', fontSize: 15 }, + msgTextDark: { color: SLATE, fontSize: 15 }, + metaRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4 }, + msgTimeLight: { fontSize: 10, color: '#d1d5db' }, + msgTimeDark: { fontSize: 10, color: '#6b7280' }, + msgStatus: { marginLeft: 4, fontSize: 10, color: '#c7d2fe' }, + + typingRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingBottom: 8, + }, + typingBubble: { + backgroundColor: '#e5e7eb', + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 8, + }, + typingText: { color: SLATE, fontSize: 12, fontWeight: '600' }, + typingToggle: { marginLeft: 8 }, + typingToggleText: { color: NAVY, fontSize: 12, fontWeight: '600' }, inputBar: { flexDirection: 'row', @@ -153,6 +696,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 14, backgroundColor: '#f3f4f6', }, + inputDisabled: { opacity: 0.6 }, sendBtn: { marginLeft: 8, backgroundColor: NAVY, @@ -161,4 +705,5 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + sendBtnDisabled: { opacity: 0.5 }, }); diff --git a/guard_app/src/screen/ShiftDetailsScreen.tsx b/guard_app/src/screen/ShiftDetailsScreen.tsx index 9d40da16e..61231c3cf 100644 --- a/guard_app/src/screen/ShiftDetailsScreen.tsx +++ b/guard_app/src/screen/ShiftDetailsScreen.tsx @@ -1,23 +1,21 @@ // src/screen/ShiftDetailsScreen.tsx import { Ionicons } from '@expo/vector-icons'; -import { RouteProp, useRoute } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import React, { useEffect, useState } from 'react'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { checkIn, checkOut } from '../api/attendance'; import LocationVerificationModal from '../components/LocationVerificationModal'; import { getAttendanceForShift, setAttendanceForShift } from '../lib/attendancestore'; +import type { RootStackParamList } from '../navigation/AppNavigator'; import { COLORS } from '../theme/colors'; import { formatDate } from '../utils/date'; import type { ShiftDto } from '../api/shifts'; -// ✅ Keep this local if you don't have a shared RootStackParamList updated -type RootStackParamList = { - ShiftDetails: { shift: ShiftDto; refresh?: () => void }; -}; - type ScreenRouteProp = RouteProp; +type Nav = NativeStackNavigationProp; type AttendanceState = { checkInTime?: string; @@ -26,6 +24,7 @@ type AttendanceState = { export default function ShiftDetailsScreen() { const route = useRoute(); + const navigation = useNavigation