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 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
@@ -31,7 +80,7 @@
-
+
{
"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