feat(移动端优化): 实现安卓原生应用的全屏布局和安全区域处理
添加安全区域插件和样式处理,优化移动端视图布局 重构路由和导航结构,改进底部导航栏 增强原生功能集成,包括状态栏和导航栏控制 优化位置服务和后台任务处理 更新语言包和样式以适应移动端体验
This commit is contained in:
@@ -10,10 +10,12 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-background-geolocation')
|
||||
implementation project(':capacitor-community-safe-area')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-app-launcher')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-geolocation')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
|
||||
<!-- Battery optimization permissions -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<!-- Boot receiver for auto-start -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
package com.ouji.factory.myapp;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Configure window for proper native behavior
|
||||
setupNativeWindow();
|
||||
}
|
||||
|
||||
private void setupNativeWindow() {
|
||||
// Enable edge-to-edge display
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
|
||||
// Configure status bar
|
||||
WindowInsetsControllerCompat windowInsetsController =
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||
|
||||
if (windowInsetsController != null) {
|
||||
// Set status bar to light mode (dark icons)
|
||||
windowInsetsController.setAppearanceLightStatusBars(true);
|
||||
// Set navigation bar to light mode (dark icons)
|
||||
windowInsetsController.setAppearanceLightNavigationBars(true);
|
||||
}
|
||||
|
||||
// Set status bar color
|
||||
getWindow().setStatusBarColor(android.graphics.Color.parseColor("#ffffff"));
|
||||
// Set navigation bar color
|
||||
getWindow().setNavigationBarColor(android.graphics.Color.parseColor("#ffffff"));
|
||||
|
||||
// Prevent screenshots and screen recording for security
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE);
|
||||
|
||||
// Keep screen on during app usage
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
// Disable pull-to-refresh overscroll effect
|
||||
getWindow().getDecorView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:windowTranslucentStatus">true</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-community-background-geolocation'
|
||||
project(':capacitor-community-background-geolocation').projectDir = new File('../node_modules/@capacitor-community/background-geolocation/android')
|
||||
|
||||
include ':capacitor-community-safe-area'
|
||||
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/@capacitor-community/safe-area/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
@@ -17,6 +20,9 @@ project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/d
|
||||
include ':capacitor-geolocation'
|
||||
project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-network'
|
||||
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- OPTIMIZATION: Simplify location_updates table schema
|
||||
-- Remove redundant and unnecessary fields for better performance
|
||||
|
||||
-- Step 1: Create new optimized table structure
|
||||
CREATE TABLE `location_updates_new` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`longitude` decimal(11,8) NOT NULL COMMENT 'Longitude first for geographic convention',
|
||||
`latitude` decimal(10,8) NOT NULL COMMENT 'Latitude second for geographic convention',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Single timestamp field',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_user_created` (`user_id`, `created_at`) COMMENT 'Composite index for user location history'
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Optimized location updates - essential fields only';
|
||||
|
||||
-- Step 2: Migrate existing data (longitude, latitude order)
|
||||
INSERT INTO `location_updates_new` (`user_id`, `longitude`, `latitude`, `created_at`)
|
||||
SELECT `user_id`, `longitude`, `latitude`, `created_at`
|
||||
FROM `location_updates`
|
||||
ORDER BY `created_at` ASC;
|
||||
|
||||
-- Step 3: Backup old table and replace with new one
|
||||
RENAME TABLE `location_updates` TO `location_updates_backup`;
|
||||
RENAME TABLE `location_updates_new` TO `location_updates`;
|
||||
|
||||
-- Step 4: Update the view to work with new schema
|
||||
DROP VIEW IF EXISTS `recent_location_updates`;
|
||||
CREATE VIEW `recent_location_updates` AS
|
||||
SELECT
|
||||
`lu`.`id` AS `id`,
|
||||
`lu`.`user_id` AS `user_id`,
|
||||
`lu`.`longitude` AS `longitude`,
|
||||
`lu`.`latitude` AS `latitude`,
|
||||
`lu`.`created_at` AS `created_at`,
|
||||
`w`.`username` AS `username`,
|
||||
`w`.`full_name` AS `full_name`,
|
||||
TIMESTAMPDIFF(MINUTE, `lu`.`created_at`, NOW()) AS `minutes_ago`
|
||||
FROM (`location_updates` `lu`
|
||||
JOIN `workers` `w` ON((`lu`.`user_id` = `w`.`id`)))
|
||||
WHERE (`lu`.`created_at` > (NOW() - INTERVAL 24 HOUR))
|
||||
ORDER BY `lu`.`created_at` DESC;
|
||||
|
||||
-- Step 5: Add comment about optimization
|
||||
ALTER TABLE `location_updates` COMMENT = 'Optimized for 30-minute updates - essential fields only (longitude, latitude, created_at)';
|
||||
|
||||
-- Verification queries (run these to verify the migration)
|
||||
-- SELECT COUNT(*) as old_count FROM location_updates_backup;
|
||||
-- SELECT COUNT(*) as new_count FROM location_updates;
|
||||
-- SELECT * FROM location_updates ORDER BY created_at DESC LIMIT 5;
|
||||
-- SELECT * FROM recent_location_updates LIMIT 5;
|
||||
|
||||
-- Note: After verifying the migration is successful, you can drop the backup table:
|
||||
-- DROP TABLE `location_updates_backup`;
|
||||
+57
-33
@@ -189,18 +189,28 @@ async function startServer() {
|
||||
}
|
||||
|
||||
// Define the geofence polygon by calling the 'polygon' function directly
|
||||
const geofence = polygon([
|
||||
[
|
||||
[101.80827335908509, 2.8350045747358337],
|
||||
[101.80822799653066, 2.8340134829130363],
|
||||
[101.80827902940462, 2.8335264317641418],
|
||||
[101.80941309326164, 2.8332772427247335],
|
||||
[101.81144873788423, 2.834596811345506],
|
||||
[101.81166988033686, 2.8345911479647157],
|
||||
[101.81199875885511, 2.83593336858695],
|
||||
[101.80827335908509, 2.8350045747358337],
|
||||
],
|
||||
])
|
||||
// const geofence = polygon([
|
||||
// [
|
||||
// [101.80827335908509, 2.8350045747358337],
|
||||
// [101.80822799653066, 2.8340134829130363],
|
||||
// [101.80827902940462, 2.8335264317641418],
|
||||
// [101.80941309326164, 2.8332772427247335],
|
||||
// [101.81144873788423, 2.834596811345506],
|
||||
// [101.81166988033686, 2.8345911479647157],
|
||||
// [101.81199875885511, 2.83593336858695],
|
||||
// [101.80827335908509, 2.8350045747358337],
|
||||
// ],
|
||||
// ])
|
||||
|
||||
const geofence = polygon([
|
||||
[
|
||||
[113.310, 23.120],
|
||||
[113.330, 23.120],
|
||||
[113.310, 23.140],
|
||||
[113.330, 23.140],
|
||||
[113.310, 23.120]
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
// Enhanced CORS configuration for HTTPS and mobile development
|
||||
@@ -897,38 +907,52 @@ async function startServer() {
|
||||
|
||||
// --- NEW NATIVE FEATURES API ENDPOINTS ---
|
||||
|
||||
// Location Update Endpoint
|
||||
// Location Update Endpoint - OPTIMIZED: Removed continuous geofence checking
|
||||
app.post('/api/location/update', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { userId, latitude, longitude, accuracy, timestamp, speed, heading, altitude } = req.body
|
||||
const { userId, latitude, longitude, checkGeofence } = req.body
|
||||
|
||||
if (!userId || !latitude || !longitude) {
|
||||
return res.status(400).json({ message: 'User ID, latitude, and longitude are required.' })
|
||||
}
|
||||
|
||||
// Convert timestamp to MySQL-compatible format
|
||||
let mysqlTimestamp
|
||||
if (timestamp) {
|
||||
if (typeof timestamp === 'string' && timestamp.includes('T')) {
|
||||
// Handle ISO 8601 format (e.g., '2025-07-04T09:00:49.192Z')
|
||||
// Convert to MySQL DATETIME format by replacing 'T' with ' ' and removing 'Z'
|
||||
mysqlTimestamp = timestamp.replace('T', ' ').replace('Z', '')
|
||||
} else if (timestamp instanceof Date) {
|
||||
// Handle Date object
|
||||
mysqlTimestamp = timestamp.toISOString().replace('T', ' ').replace('Z', '')
|
||||
} else {
|
||||
// Fallback to current time for invalid formats
|
||||
mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '')
|
||||
// OPTIMIZATION: Only check geofence when explicitly requested
|
||||
// This reduces unnecessary processing on every location update
|
||||
if (checkGeofence === true) {
|
||||
try {
|
||||
const userLocation = point([longitude, latitude]);
|
||||
const isWithinGeofence = booleanPointInPolygon(userLocation, geofence);
|
||||
|
||||
if (!isWithinGeofence) {
|
||||
// User is outside the geofence - log security alert silently
|
||||
const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' });
|
||||
|
||||
const alertData = {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
timestamp: new Date().toISOString(),
|
||||
distance_from_geofence: distance.toFixed(2),
|
||||
check_type: 'scheduled_update' // Indicate this was a scheduled check
|
||||
};
|
||||
|
||||
// Log geofence violation to security_alerts table
|
||||
await logSecurityAlert(userId, 'geofence_violation', alertData, db);
|
||||
|
||||
console.log(`OPTIMIZED: Geofence violation detected for user ${userId}: ${distance.toFixed(2)} meters outside boundary`);
|
||||
} else {
|
||||
console.log(`OPTIMIZED: User ${userId} within geofence during scheduled check`);
|
||||
}
|
||||
} catch (geofenceError) {
|
||||
console.error('OPTIMIZED: Error checking geofence:', geofenceError);
|
||||
// Continue with location update even if geofence check fails
|
||||
}
|
||||
} else {
|
||||
// Use current time if no timestamp provided
|
||||
mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '')
|
||||
}
|
||||
|
||||
// Insert location update
|
||||
// OPTIMIZED: Simplified location update - only essential fields
|
||||
// No need for timestamp conversion as we use created_at with NOW()
|
||||
await db.execute(
|
||||
'INSERT INTO location_updates (user_id, latitude, longitude, accuracy, timestamp, speed, heading, altitude, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())',
|
||||
[userId, latitude, longitude, accuracy || null, mysqlTimestamp, speed || null, heading || null, altitude || null]
|
||||
'INSERT INTO location_updates (user_id, longitude, latitude, created_at) VALUES (?, ?, ?, NOW())',
|
||||
[userId, longitude, latitude]
|
||||
)
|
||||
|
||||
res.json({ message: 'Location updated successfully' })
|
||||
|
||||
@@ -3,11 +3,25 @@
|
||||
"appName": "nilai-clock",
|
||||
"webDir": "dist",
|
||||
"plugins": {
|
||||
"SafeArea": {
|
||||
"enabled": true,
|
||||
"customColorsForSystemBars": true,
|
||||
"statusBarColor": "#2563eb",
|
||||
"statusBarContent": "light",
|
||||
"navigationBarColor": "#ffffff",
|
||||
"navigationBarContent": "dark",
|
||||
"offset": 0
|
||||
},
|
||||
"Geolocation": {
|
||||
"enableHighAccuracy": true,
|
||||
"timeout": 10000,
|
||||
"maximumAge": 3600000
|
||||
},
|
||||
"LocalNotifications": {
|
||||
"smallIcon": "ic_stat_location_on",
|
||||
"iconColor": "#0066CC",
|
||||
"sound": null
|
||||
},
|
||||
"Preferences": {
|
||||
"group": "NilaiClockApp"
|
||||
},
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
|
||||
Generated
+20
@@ -9,12 +9,14 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@capacitor-community/background-geolocation": "^1.2.22",
|
||||
"@capacitor-community/safe-area": "^7.0.0-alpha.1",
|
||||
"@capacitor/android": "^7.4.0",
|
||||
"@capacitor/app": "^7.0.1",
|
||||
"@capacitor/app-launcher": "^7.0.1",
|
||||
"@capacitor/core": "^7.4.0",
|
||||
"@capacitor/device": "^7.0.1",
|
||||
"@capacitor/geolocation": "^7.1.2",
|
||||
"@capacitor/local-notifications": "^7.0.1",
|
||||
"@capacitor/network": "^7.0.1",
|
||||
"@capacitor/preferences": "^7.0.1",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
@@ -601,6 +603,15 @@
|
||||
"@capacitor/core": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor-community/safe-area": {
|
||||
"version": "7.0.0-alpha.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/safe-area/-/safe-area-7.0.0-alpha.1.tgz",
|
||||
"integrity": "sha512-N6ktkRiofqrt+N/vQzh4jcWY8IzJ4TVw1tKAPssvbddWFTyZzXzSdBeX/l/nracJF0PZqmLdA7JYTBfnOxXQYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-7.4.1.tgz",
|
||||
@@ -691,6 +702,15 @@
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-7.0.1.tgz",
|
||||
"integrity": "sha512-GJewoiqiTLXNNRxqeJDi6vxj1Y37jLFI3KSdAM2Omvxew4ewyBSCjwOtXMQaEg+lvzGHtK6FPrSc2v/2EcL0wA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/network": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/network/-/network-7.0.1.tgz",
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/background-geolocation": "^1.2.22",
|
||||
"@capacitor-community/safe-area": "^7.0.0-alpha.1",
|
||||
"@capacitor/android": "^7.4.0",
|
||||
"@capacitor/app": "^7.0.1",
|
||||
"@capacitor/app-launcher": "^7.0.1",
|
||||
"@capacitor/core": "^7.4.0",
|
||||
"@capacitor/device": "^7.0.1",
|
||||
"@capacitor/geolocation": "^7.1.2",
|
||||
"@capacitor/local-notifications": "^7.0.1",
|
||||
"@capacitor/network": "^7.0.1",
|
||||
"@capacitor/preferences": "^7.0.1",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
|
||||
+115
-5
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||
<!-- Mobile-native layout without top bar -->
|
||||
<main class="pb-20">
|
||||
<main class="safe-area-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { App } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { SafeArea } from '@capacitor-community/safe-area'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
import { authService } from '@/services/authService.js'
|
||||
import BottomNavigation from '@/components/BottomNavigation.vue'
|
||||
@@ -25,27 +26,106 @@ const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
|
||||
// Show bottom navigation only for worker routes
|
||||
const showBottomNav = computed(() => {
|
||||
return route.path.startsWith('/worker/')
|
||||
})
|
||||
|
||||
// Setup native touch handling to prevent unwanted gestures
|
||||
const setupNativeTouchHandling = () => {
|
||||
let startY = 0
|
||||
let startX = 0
|
||||
|
||||
// Prevent pull-to-refresh and unwanted vertical swipes
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY
|
||||
startX = e.touches[0].clientX
|
||||
}, { passive: false })
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
const currentY = e.touches[0].clientY
|
||||
const currentX = e.touches[0].clientX
|
||||
const deltaY = currentY - startY
|
||||
const deltaX = currentX - startX
|
||||
|
||||
// Prevent pull-to-refresh (downward swipe at top of page)
|
||||
// if (window.scrollY === 0 && deltaY > 0) {
|
||||
// e.preventDefault()
|
||||
// return false
|
||||
// }
|
||||
|
||||
// Prevent overscroll at bottom
|
||||
// const isAtBottom = window.scrollY >= document.documentElement.scrollHeight - window.innerHeight - 10
|
||||
// if (isAtBottom && deltaY < 0) {
|
||||
// e.preventDefault()
|
||||
// return false
|
||||
// }
|
||||
|
||||
// Allow only vertical scrolling for content, prevent horizontal swipes
|
||||
// if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
|
||||
// e.preventDefault()
|
||||
// return false
|
||||
// }
|
||||
}, { passive: false })
|
||||
|
||||
// Prevent context menu on long press
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
return false
|
||||
})
|
||||
|
||||
// Prevent double-tap zoom
|
||||
let lastTouchEnd = 0
|
||||
document.addEventListener('touchend', (e) => {
|
||||
const now = new Date().getTime()
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
e.preventDefault()
|
||||
}
|
||||
lastTouchEnd = now
|
||||
}, false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
isLoggedIn.value = !!sessionStorage.getItem('userId')
|
||||
async () => {
|
||||
isLoggedIn.value = await authService.isAuthenticated()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize SafeArea plugin for proper safe area handling
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
await SafeArea.enable({
|
||||
config: {
|
||||
customColorsForSystemBars: true,
|
||||
statusBarColor: '#2563eb', // Blue-600 to match header
|
||||
statusBarContent: 'light',
|
||||
navigationBarColor: '#ffffff', // White
|
||||
navigationBarContent: 'dark',
|
||||
offset: 0
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize SafeArea:', error)
|
||||
}
|
||||
|
||||
// Disable unwanted touch gestures for native app
|
||||
setupNativeTouchHandling()
|
||||
}
|
||||
|
||||
// Restore language
|
||||
const savedLang = localStorage.getItem('lang')
|
||||
if (savedLang) {
|
||||
locale.value = savedLang
|
||||
}
|
||||
|
||||
// Initialize authentication state
|
||||
isLoggedIn.value = await authService.isAuthenticated()
|
||||
|
||||
// Initialize native services
|
||||
try {
|
||||
console.log('Initializing native services...')
|
||||
@@ -68,6 +148,16 @@ onMounted(async () => {
|
||||
console.log('Auto-login successful')
|
||||
isLoggedIn.value = true
|
||||
|
||||
// Sync sessionStorage for compatibility with existing code
|
||||
sessionStorage.setItem('userId', autoLoginResult.userId.toString())
|
||||
sessionStorage.setItem('userRole', autoLoginResult.userRole)
|
||||
|
||||
// Get and store the token if available
|
||||
const token = await authService.getAuthToken()
|
||||
if (token) {
|
||||
sessionStorage.setItem('token', token)
|
||||
}
|
||||
|
||||
// Start native services
|
||||
await nativeServicesManager.onUserLogin()
|
||||
|
||||
@@ -92,5 +182,25 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* All styles are now handled by Tailwind CSS classes in the template. */
|
||||
.safe-area-main {
|
||||
/* Use full viewport height minus bottom navigation */
|
||||
min-height: calc(100vh - 4rem);
|
||||
/* No padding-top here since body already handles safe area */
|
||||
}
|
||||
|
||||
/* Disable pull-to-refresh and overscroll behaviors */
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
html, body {
|
||||
touch-action: pan-y;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,102 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Android-specific native app styles */
|
||||
@layer base {
|
||||
html {
|
||||
/* Prevent zoom and unwanted touch behaviors */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Prevent overscroll and pull-to-refresh */
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Prevent text selection in native app */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
/* Prevent tap highlight */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* Remove body padding - let individual components handle safe areas */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Prevent unwanted touch behaviors on all elements */
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Allow text selection only in input fields */
|
||||
input, textarea {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area utilities */
|
||||
@layer utilities {
|
||||
.safe-top {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-left {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-right {
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-all {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Smooth scrolling for mobile */
|
||||
.smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Ensure proper viewport height for mobile */
|
||||
.mobile-viewport {
|
||||
height: 100vh;
|
||||
height: 100dvh; /* Dynamic viewport height for mobile browsers */
|
||||
}
|
||||
|
||||
/* Fixed header with safe area */
|
||||
.fixed-header-safe {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
/* Main content with proper spacing for fixed header */
|
||||
.main-with-fixed-header {
|
||||
padding-top: calc(var(--safe-area-inset-top) + 5rem); /* 5rem = header height */
|
||||
}
|
||||
|
||||
/* Main content with proper spacing for fixed header and bottom nav */
|
||||
.main-with-fixed-header-and-nav {
|
||||
padding-top: calc(var(--safe-area-inset-top) + 5rem); /* 5rem = header height */
|
||||
padding-bottom: calc(var(--safe-area-inset-bottom) + 4rem); /* 4rem = bottom nav height */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-50">
|
||||
<div class="flex">
|
||||
<nav class="bottom-nav-container">
|
||||
<div class="bottom-nav-content">
|
||||
<!-- Clock In Section -->
|
||||
<router-link
|
||||
to="/worker/dashboard"
|
||||
@@ -11,14 +11,14 @@
|
||||
<span class="text-xs font-medium">{{ $t('clockIn') }}</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Personal Section -->
|
||||
<!-- Settings Section -->
|
||||
<router-link
|
||||
to="/worker/personal"
|
||||
to="/worker/settings"
|
||||
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
|
||||
:class="isPersonalActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
|
||||
:class="isSettingsActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
|
||||
>
|
||||
<component :is="isPersonalActive ? UserIconSolid : UserIconOutline" class="w-7 h-7 mb-1" />
|
||||
<span class="text-xs font-medium">{{ $t('personal') }}</span>
|
||||
<component :is="isSettingsActive ? SettingsIconSolid : SettingsIconOutline" class="w-7 h-7 mb-1" />
|
||||
<span class="text-xs font-medium">{{ $t('setting') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -27,25 +27,85 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ClockIcon as ClockIconOutline, UserIcon as UserIconOutline } from '@heroicons/vue/24/outline'
|
||||
import { ClockIcon as ClockIconSolid, UserIcon as UserIconSolid } from '@heroicons/vue/24/solid'
|
||||
import { ClockIcon as ClockIconOutline, Cog6ToothIcon as SettingsIconOutline } from '@heroicons/vue/24/outline'
|
||||
import { ClockIcon as ClockIconSolid, Cog6ToothIcon as SettingsIconSolid } from '@heroicons/vue/24/solid'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Computed properties for active states
|
||||
const isClockInActive = computed(() => route.path === '/worker/dashboard')
|
||||
const isPersonalActive = computed(() =>
|
||||
route.path.includes('/worker/personal') ||
|
||||
const isSettingsActive = computed(() =>
|
||||
route.path.includes('/worker/settings') ||
|
||||
route.path.includes('/worker/history') ||
|
||||
route.path.includes('/worker/change-password')
|
||||
route.path.includes('/worker/change-password') ||
|
||||
route.path.includes('/worker/services-status')
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional mobile-specific styles */
|
||||
/* Bottom navigation container - stable positioning */
|
||||
.bottom-nav-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
/* Prevent movement during scroll or pull gestures */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
/* Ensure it stays in place */
|
||||
will-change: auto;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.bottom-nav-content {
|
||||
background-color: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
/* Safe area handling for Android navigation bar */
|
||||
padding-bottom: 0;
|
||||
/* Prevent any unwanted interactions */
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Prevent any scroll-related movement */
|
||||
.bottom-nav-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Additional stability for mobile devices */
|
||||
@media (max-width: 640px) {
|
||||
nav {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
.bottom-nav-container {
|
||||
/* Force hardware acceleration */
|
||||
transform: translate3d(0, 0, 0);
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
/* Prevent any movement during overscroll */
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
}
|
||||
|
||||
.bottom-nav-content {
|
||||
/* Ensure minimum safe area padding */
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), 8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent pull-to-refresh interference */
|
||||
@supports (-webkit-overflow-scrolling: touch) {
|
||||
.bottom-nav-container {
|
||||
-webkit-overflow-scrolling: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-3
@@ -33,8 +33,8 @@
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"noClockHistory": "You have no clocking history.",
|
||||
"clockHistoryFetchFail": "Failed to fetch clock history:",
|
||||
"viewClockHistory": "View My Clock History →",
|
||||
"changePassword": "Change My Password →",
|
||||
"viewClockHistory": "View My Clock History",
|
||||
"changePassword": "Change My Password",
|
||||
"invalidCurrentPassword": "Invalid current password.",
|
||||
|
||||
"successClockIn": "Successfully clocked in.",
|
||||
@@ -104,6 +104,7 @@
|
||||
"allWorkersSelected": "All Workers ({count}) Selected",
|
||||
"noWorkersSelected": "No workers selected.",
|
||||
"reportSettings": "2. Report Settings",
|
||||
"setting": "Setting",
|
||||
"monthlySalary": "Monthly Salary (RM)",
|
||||
"salaryAppliedNote": "Applied to all selected workers.",
|
||||
"salaryPlaceholder": "e.g., 3000",
|
||||
@@ -246,4 +247,4 @@
|
||||
"systemServicesStatus": "System services and security status",
|
||||
"updateYourPassword": "Update your account password",
|
||||
"signOutOfAccount": "Sign out of your account"
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -31,8 +31,8 @@
|
||||
"backToDashboard": "Kembali ke Papan Pemuka",
|
||||
"noClockHistory": "Tiada rekod kehadiran.",
|
||||
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
|
||||
"viewClockHistory": "Lihat Sejarah Kehadiran Saya →",
|
||||
"changePassword": "Tukar Kata Laluan Saya →",
|
||||
"viewClockHistory": "Lihat Sejarah Kehadiran Saya",
|
||||
"changePassword": "Tukar Kata Laluan Saya",
|
||||
"invalidCurrentPassword": "Kata laluan semasa tidak sah.",
|
||||
"successClockIn": "Berjaya masuk kerja.",
|
||||
"successClockOut": "Berjaya keluar kerja.",
|
||||
@@ -44,6 +44,7 @@
|
||||
"newPassword": "Kata Laluan Baharu",
|
||||
"confirmNewPassword": "Sahkan Kata Laluan Baharu",
|
||||
"updating": "Mengemaskini...",
|
||||
"setting": "Tetapan",
|
||||
|
||||
"tabPersonnel": "Personel",
|
||||
"tabAttendance": "Kehadiran",
|
||||
@@ -240,4 +241,4 @@
|
||||
"locationTrackingStarted": "Penjejakan lokasi dimulakan dengan jayanya",
|
||||
"failedToStartLocationTracking": "Gagal memulakan penjejakan lokasi",
|
||||
"securityCheckFailed": "Pemeriksaan keselamatan gagal"
|
||||
}
|
||||
}
|
||||
+25
-11
@@ -3,7 +3,9 @@ import LoginView from '../views/LoginView.vue'
|
||||
import WorkerDashboardView from '../views/WorkerDashboardView.vue'
|
||||
import WorkerHistoryView from '../views/WorkerHistoryView.vue'
|
||||
import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||
import PersonalView from '../views/PersonalView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
import NativeServicesStatus from '../views/NativeServicesStatus.vue'
|
||||
import { authService } from '@/services/authService.js'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@@ -28,30 +30,42 @@ const router = createRouter({
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/personal',
|
||||
name: 'worker-personal',
|
||||
component: PersonalView,
|
||||
path: '/worker/services-status',
|
||||
name: 'worker-services-status',
|
||||
component: NativeServicesStatus,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/settings',
|
||||
name: 'worker-settings',
|
||||
component: SettingsView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// --- ALIGNMENT CHANGE: Navigation Guard ---
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isLoggedIn = !!sessionStorage.getItem('userId')
|
||||
const userRole = sessionStorage.getItem('userRole')
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Check authentication using persistent storage via authService
|
||||
const isAuthenticated = await authService.isAuthenticated()
|
||||
|
||||
// Get user data from both persistent storage and sessionStorage for compatibility
|
||||
let userRole = await authService.getUserRole()
|
||||
if (!userRole) {
|
||||
userRole = sessionStorage.getItem('userRole')
|
||||
}
|
||||
|
||||
console.log('🛡️ ROUTER GUARD:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
isLoggedIn,
|
||||
isAuthenticated,
|
||||
userRole,
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
requiredRole: to.meta.role
|
||||
})
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (isLoggedIn) {
|
||||
if (isAuthenticated) {
|
||||
// Check if user has the required role
|
||||
if (to.meta.role && to.meta.role === userRole) {
|
||||
console.log('✅ ACCESS GRANTED - Correct role')
|
||||
@@ -68,8 +82,8 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is not logged in, redirect to login page
|
||||
console.log('❌ NOT LOGGED IN - Redirecting to login')
|
||||
// User is not authenticated, redirect to login page
|
||||
console.log('❌ NOT AUTHENTICATED - Redirecting to login')
|
||||
next('/')
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -228,6 +228,11 @@ class AuthService {
|
||||
credentials
|
||||
)
|
||||
|
||||
// Cache worker data
|
||||
if (decodedToken.role === 'worker') {
|
||||
await this.cacheWorkerData(decodedToken.userId, { full_name: data.fullName })
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: decodedToken.userId,
|
||||
@@ -258,6 +263,9 @@ class AuthService {
|
||||
await this.removeSecureItem(this.tokenKey)
|
||||
await this.removeSecureItem(this.userIdKey)
|
||||
await this.removeSecureItem(this.userRoleKey)
|
||||
|
||||
// Also clear stored credentials for auto-login
|
||||
await this.clearStoredCredentials()
|
||||
|
||||
// Clear session storage for web compatibility
|
||||
if (!this.isNative) {
|
||||
@@ -266,7 +274,7 @@ class AuthService {
|
||||
sessionStorage.removeItem('userRole')
|
||||
}
|
||||
|
||||
console.log('Logout successful')
|
||||
console.log('Logout successful, all stored data cleared')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to logout:', error)
|
||||
@@ -370,6 +378,26 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedWorkerData(userId) {
|
||||
try {
|
||||
const workerData = await this.getSecureItem(`worker_data_${userId}`)
|
||||
return workerData ? JSON.parse(workerData) : null
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached worker data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async cacheWorkerData(userId, data) {
|
||||
try {
|
||||
await this.setSecureItem(`worker_data_${userId}`, JSON.stringify(data))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to cache worker data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for secure storage
|
||||
async setSecureItem(key, value) {
|
||||
if (this.isNative) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { registerPlugin } from '@capacitor/core'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
const BackgroundGeolocation = registerPlugin('BackgroundGeolocation')
|
||||
@@ -9,8 +10,25 @@ class BackgroundLocationService {
|
||||
constructor() {
|
||||
this.isInitialized = false
|
||||
this.isTracking = false
|
||||
this.isClockedIn = false // Track worker clock-in status
|
||||
|
||||
// PRODUCTION INTERVALS - 30 minutes for reliable background operation
|
||||
this.locationUpdateInterval = 30 * 60 * 1000 // 30 minutes in milliseconds
|
||||
this.heartbeatInterval = 5 * 60 * 1000 // 5 minutes for service heartbeat
|
||||
this.notificationRefreshInterval = 10 * 60 * 1000 // 10 minutes for notification refresh (less aggressive)
|
||||
|
||||
this.lastLocationUpdate = null
|
||||
this.lastBackgroundLocation = null // Store last location from background watcher
|
||||
this.isInBackground = false // Track if app is in background
|
||||
|
||||
// Timer management
|
||||
this.periodicUpdateTimer = null
|
||||
this.heartbeatTimer = null
|
||||
this.notificationRefreshTimer = null
|
||||
|
||||
// Service state tracking
|
||||
this.lastHeartbeat = null
|
||||
this.serviceStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,25 +41,56 @@ class BackgroundLocationService {
|
||||
console.warn('Background location limited on web platform')
|
||||
}
|
||||
|
||||
// Request location permissions first
|
||||
// Request location permissions first with proper error handling
|
||||
const permissions = await Geolocation.requestPermissions()
|
||||
|
||||
if (permissions.location !== 'granted') {
|
||||
console.error('Location permission not granted')
|
||||
console.error('PRODUCTION: Location permission DENIED')
|
||||
console.error('PRODUCTION: Background location tracking requires "Allow all the time" permission')
|
||||
|
||||
// Show user-friendly guidance
|
||||
alert('Location permission is required for work attendance monitoring. Please:\n\n1. Go to App Settings\n2. Select Permissions\n3. Choose Location\n4. Select "Allow all the time"\n\nThis ensures accurate attendance tracking.')
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('Location permissions granted:', permissions)
|
||||
console.log('PRODUCTION: Location permissions granted:', permissions)
|
||||
|
||||
// Initialize background geolocation with community plugin
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
this.watcherId = await BackgroundGeolocation.addWatcher(
|
||||
{
|
||||
// Location request options
|
||||
// PRODUCTION CONFIGURATION - Optimized for 30-minute intervals
|
||||
requestPermissions: true,
|
||||
stale: false,
|
||||
distanceFilter: 10, // meters
|
||||
distanceFilter: 50, // 50 meters - reduce sensitivity for battery life
|
||||
backgroundMessage: "Work attendance monitoring is active.",
|
||||
backgroundTitle: "Work Location Tracking",
|
||||
|
||||
// Battery-optimized settings for long-term operation
|
||||
enableHighAccuracy: false, // Use network location for better battery life
|
||||
interval: 30 * 60 * 1000, // 30 minutes - matches production requirement
|
||||
fastestInterval: 5 * 60 * 1000, // 5 minutes minimum
|
||||
activitiesInterval: 30 * 60 * 1000, // 30 minutes for activity detection
|
||||
saveBatteryOnBackground: true, // Optimize for battery in background
|
||||
|
||||
// Foreground service notification (handled by plugin)
|
||||
notificationTitle: "Work Location Tracking",
|
||||
notificationText: "Monitoring location for work attendance",
|
||||
notificationIconColor: "#0066CC",
|
||||
notificationIconLarge: "ic_launcher",
|
||||
notificationIconSmall: "ic_notification",
|
||||
|
||||
// Critical settings for Android power management
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
locationUpdatesPaused: false,
|
||||
showsBackgroundLocationIndicator: true,
|
||||
allowsBackgroundLocationUpdates: true,
|
||||
|
||||
// Additional production settings
|
||||
desiredAccuracy: 100, // 100 meters accuracy is sufficient for geofencing
|
||||
maxWaitTime: 5 * 60 * 1000, // 5 minutes max wait for location
|
||||
deferredUpdatesInterval: 30 * 60 * 1000 // 30 minutes for deferred updates
|
||||
},
|
||||
(location, error) => {
|
||||
if (error) {
|
||||
@@ -138,20 +187,40 @@ class BackgroundLocationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic location updates every 30 minutes
|
||||
* Start periodic location updates - PRODUCTION: 30 minutes
|
||||
*/
|
||||
startPeriodicUpdates() {
|
||||
if (this.periodicUpdateTimer) {
|
||||
clearInterval(this.periodicUpdateTimer)
|
||||
}
|
||||
this.stopPeriodicUpdates() // Clean up any existing timer
|
||||
|
||||
console.log(`Starting PRODUCTION periodic updates every ${this.locationUpdateInterval/60000} minutes`)
|
||||
this.serviceStartTime = Date.now()
|
||||
|
||||
this.periodicUpdateTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.getCurrentLocationAndSend()
|
||||
console.log('PRODUCTION: Executing 30-minute location update with geofence check')
|
||||
await this.getCurrentLocationAndSend({ checkGeofence: true })
|
||||
this.lastHeartbeat = Date.now()
|
||||
} catch (error) {
|
||||
console.error('Periodic location update failed:', error)
|
||||
console.error('PRODUCTION: 30-minute location update failed, trying fallback:', error)
|
||||
|
||||
// Fallback: use last known background location if available and recent
|
||||
if (this.lastBackgroundLocation) {
|
||||
const locationAge = Date.now() - this.lastBackgroundLocation.timestamp
|
||||
if (locationAge < 60 * 60 * 1000) { // Use if less than 1 hour old
|
||||
console.log('PRODUCTION: Using background location fallback with geofence check')
|
||||
try {
|
||||
await this.sendLocationToServer(this.lastBackgroundLocation, { checkGeofence: true })
|
||||
this.lastHeartbeat = Date.now()
|
||||
} catch (fallbackError) {
|
||||
console.error('PRODUCTION: Fallback location update also failed:', fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, this.locationUpdateInterval)
|
||||
|
||||
// Start heartbeat timer for service health monitoring
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,62 +231,114 @@ class BackgroundLocationService {
|
||||
clearInterval(this.periodicUpdateTimer)
|
||||
this.periodicUpdateTimer = null
|
||||
}
|
||||
this.stopHeartbeat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current location and send to server
|
||||
* Start heartbeat timer for service health monitoring
|
||||
*/
|
||||
async getCurrentLocationAndSend() {
|
||||
startHeartbeat() {
|
||||
this.stopHeartbeat() // Clean up any existing timer
|
||||
|
||||
console.log(`Starting service heartbeat every ${this.heartbeatInterval/60000} minutes`)
|
||||
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
try {
|
||||
console.log('PRODUCTION: Service heartbeat - checking health')
|
||||
|
||||
// Check if main service is still running
|
||||
if (this.isClockedIn && !this.isTracking) {
|
||||
console.warn('PRODUCTION: Service stopped unexpectedly, restarting...')
|
||||
await this.startTracking()
|
||||
}
|
||||
|
||||
// Check if we haven't sent location in too long (2x the expected interval)
|
||||
const timeSinceLastUpdate = Date.now() - (this.lastHeartbeat || this.serviceStartTime || Date.now())
|
||||
if (timeSinceLastUpdate > (this.locationUpdateInterval * 2)) {
|
||||
console.warn('PRODUCTION: No location updates for too long, forcing update...')
|
||||
try {
|
||||
await this.getCurrentLocationAndSend()
|
||||
this.lastHeartbeat = Date.now()
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Forced location update failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh foreground notification to keep service alive
|
||||
if (this.isClockedIn) {
|
||||
await this.refreshForegroundNotification()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Heartbeat failed:', error)
|
||||
}
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat timer
|
||||
*/
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current location and send to server - OPTIMIZED with options
|
||||
*/
|
||||
async getCurrentLocationAndSend(options = {}) {
|
||||
try {
|
||||
const position = await Geolocation.getCurrentPosition({
|
||||
timeout: 30000,
|
||||
maximumAge: 5000,
|
||||
enableHighAccuracy: true
|
||||
timeout: 60000, // Increased timeout for background operation
|
||||
maximumAge: 30000, // Allow slightly older location data in background
|
||||
enableHighAccuracy: false // Use less accurate but more reliable location in background
|
||||
})
|
||||
|
||||
// Convert to format expected by sendLocationToServer
|
||||
// OPTIMIZED: Convert to simplified format (only essential fields)
|
||||
const location = {
|
||||
coords: {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
speed: position.coords.speed,
|
||||
heading: position.coords.heading,
|
||||
altitude: position.coords.altitude
|
||||
longitude: position.coords.longitude
|
||||
},
|
||||
timestamp: position.timestamp
|
||||
}
|
||||
|
||||
await this.sendLocationToServer(location)
|
||||
await this.sendLocationToServer(location, options)
|
||||
return location
|
||||
} catch (error) {
|
||||
console.error('Failed to get current location:', error)
|
||||
console.error('OPTIMIZED: Failed to get current location:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send location data to server
|
||||
* Send location data to server - OPTIMIZED with simplified data
|
||||
*/
|
||||
async sendLocationToServer(location) {
|
||||
async sendLocationToServer(location, options = {}) {
|
||||
try {
|
||||
// Only send location updates if worker is clocked in
|
||||
if (!this.isClockedIn) {
|
||||
console.log('OPTIMIZED: Worker not clocked in, skipping location update')
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token') || sessionStorage.getItem('token')
|
||||
const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId')
|
||||
|
||||
if (!token || !userId) {
|
||||
console.warn('No authentication token or user ID available')
|
||||
console.warn('OPTIMIZED: No authentication token or user ID available')
|
||||
return
|
||||
}
|
||||
|
||||
// OPTIMIZED: Send only essential location data
|
||||
const locationData = {
|
||||
userId: userId,
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy,
|
||||
timestamp: new Date(location.timestamp).toISOString(),
|
||||
speed: location.coords.speed || 0,
|
||||
heading: location.coords.heading || 0,
|
||||
altitude: location.coords.altitude || 0
|
||||
// OPTIMIZATION: Only include geofence check flag when needed
|
||||
checkGeofence: options.checkGeofence === true
|
||||
}
|
||||
|
||||
await apiFetch('/api/location/update', {
|
||||
@@ -226,10 +347,15 @@ class BackgroundLocationService {
|
||||
})
|
||||
|
||||
this.lastLocationUpdate = new Date()
|
||||
console.log('Location sent to server successfully:', locationData)
|
||||
console.log('OPTIMIZED: Location sent to server successfully:', {
|
||||
userId: locationData.userId,
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
checkGeofence: locationData.checkGeofence
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send location to server:', error)
|
||||
console.error('OPTIMIZED: Failed to send location to server:', error)
|
||||
// Store location locally for retry later if needed
|
||||
this.storeLocationForRetry(location)
|
||||
}
|
||||
@@ -290,9 +416,25 @@ class BackgroundLocationService {
|
||||
|
||||
// Event handlers
|
||||
onLocationUpdate(location) {
|
||||
console.log('Location update received:', location)
|
||||
// Location is automatically sent to server via HTTP config
|
||||
// But we can also handle it manually if needed
|
||||
console.log('Location update received from background watcher:', location)
|
||||
|
||||
// Store the latest location from background watcher
|
||||
this.lastBackgroundLocation = {
|
||||
coords: {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
accuracy: location.accuracy,
|
||||
speed: location.speed || 0,
|
||||
heading: location.bearing || 0,
|
||||
altitude: location.altitude || 0
|
||||
},
|
||||
timestamp: location.time || Date.now()
|
||||
}
|
||||
|
||||
// Send this location update to server if worker is clocked in
|
||||
this.sendLocationToServer(this.lastBackgroundLocation).catch(error => {
|
||||
console.error('Failed to send background location update:', error)
|
||||
})
|
||||
}
|
||||
|
||||
onLocationError(error) {
|
||||
@@ -323,6 +465,331 @@ class BackgroundLocationService {
|
||||
return this.isTracking
|
||||
}
|
||||
|
||||
/**
|
||||
* Set worker clock-in status
|
||||
*/
|
||||
async setClockedInStatus(isClockedIn) {
|
||||
console.log(`PRODUCTION: Setting worker clock-in status to: ${isClockedIn}`)
|
||||
this.isClockedIn = isClockedIn
|
||||
|
||||
if (isClockedIn) {
|
||||
// Worker clocked in - check permissions first
|
||||
const permissionsGranted = await this.checkAndRequestAllPermissions()
|
||||
if (!permissionsGranted) {
|
||||
console.error('PRODUCTION: Cannot start location tracking - permissions denied')
|
||||
return false
|
||||
}
|
||||
|
||||
// Start production location tracking
|
||||
if (!this.isTracking) {
|
||||
try {
|
||||
await this.startTracking()
|
||||
console.log('PRODUCTION: Location tracking started successfully')
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to start location tracking after clock-in:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Start foreground service for battery optimization protection
|
||||
try {
|
||||
const serviceStarted = await this.startForegroundService()
|
||||
if (!serviceStarted) {
|
||||
console.warn('PRODUCTION: Foreground service failed to start, tracking may be unreliable')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to start foreground service:', error)
|
||||
}
|
||||
|
||||
// Request battery optimization exemption for reliable background operation
|
||||
try {
|
||||
await this.requestBatteryOptimizationExemption()
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to request battery optimization exemption:', error)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
} else {
|
||||
// Worker clocked out - stop all tracking services
|
||||
if (this.isTracking) {
|
||||
try {
|
||||
await this.stopTracking()
|
||||
console.log('PRODUCTION: Location tracking stopped successfully')
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to stop location tracking after clock-out:', error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stopForegroundService()
|
||||
console.log('PRODUCTION: Foreground service stopped successfully')
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to stop foreground service:', error)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and request all necessary permissions for production operation
|
||||
*/
|
||||
async checkAndRequestAllPermissions() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('PRODUCTION: Checking all required permissions...')
|
||||
|
||||
// Check location permissions
|
||||
const locationPermissions = await Geolocation.checkPermissions()
|
||||
if (locationPermissions.location !== 'granted') {
|
||||
console.warn('PRODUCTION: Location permission not granted, requesting...')
|
||||
const requested = await Geolocation.requestPermissions()
|
||||
if (requested.location !== 'granted') {
|
||||
this.showPermissionGuidance('location')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check notification permissions
|
||||
const notificationPermissions = await LocalNotifications.checkPermissions()
|
||||
if (notificationPermissions.display !== 'granted') {
|
||||
console.warn('PRODUCTION: Notification permission not granted, requesting...')
|
||||
const requested = await LocalNotifications.requestPermissions()
|
||||
if (requested.display !== 'granted') {
|
||||
this.showPermissionGuidance('notification')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
console.log('PRODUCTION: All permissions granted successfully')
|
||||
return true
|
||||
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to check/request permissions:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user-friendly permission guidance
|
||||
*/
|
||||
showPermissionGuidance(permissionType) {
|
||||
const messages = {
|
||||
location: 'Location permission is required for work attendance monitoring.\n\nPlease:\n1. Go to App Settings\n2. Select Permissions\n3. Choose Location\n4. Select "Allow all the time"\n\nThis ensures accurate attendance tracking.',
|
||||
notification: 'Notification permission is required for background location tracking.\n\nPlease:\n1. Go to App Settings\n2. Select Permissions\n3. Enable Notifications\n\nThis keeps the location service active when the app is in background.'
|
||||
}
|
||||
|
||||
alert(messages[permissionType] || 'Permission required for proper app functionality.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Request battery optimization exemption for reliable background operation
|
||||
*/
|
||||
async requestBatteryOptimizationExemption() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('PRODUCTION: Requesting battery optimization exemption...')
|
||||
|
||||
// Show user guidance for battery optimization
|
||||
const shouldShowGuidance = localStorage.getItem('battery_guidance_shown') !== 'true'
|
||||
if (shouldShowGuidance) {
|
||||
alert('For reliable background location tracking, please:\n\n1. Go to Settings > Apps > Nilai Clock\n2. Select Battery\n3. Choose "Don\'t optimize" or "Allow background activity"\n\nThis prevents the system from stopping location tracking.')
|
||||
localStorage.setItem('battery_guidance_shown', 'true')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to request battery optimization exemption:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current clock-in status
|
||||
*/
|
||||
getClockedInStatus() {
|
||||
return this.isClockedIn
|
||||
}
|
||||
|
||||
/**
|
||||
* Set background state and adjust tracking accordingly
|
||||
*/
|
||||
setBackgroundState(isInBackground) {
|
||||
console.log(`App background state changed: ${isInBackground}`)
|
||||
this.isInBackground = isInBackground
|
||||
|
||||
// Restart periodic updates with appropriate interval
|
||||
if (this.isTracking && this.isClockedIn) {
|
||||
this.stopPeriodicUpdates()
|
||||
this.startPeriodicUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start foreground service to keep location tracking active when device is locked
|
||||
*/
|
||||
async startForegroundService() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Request notification permissions first and handle denial properly
|
||||
const permission = await LocalNotifications.requestPermissions()
|
||||
if (permission.display !== 'granted') {
|
||||
console.error('PRODUCTION: Notification permission DENIED - foreground service cannot start')
|
||||
console.error('PRODUCTION: Please grant notification permission in app settings for reliable background operation')
|
||||
|
||||
// Show user-friendly error message
|
||||
alert('Notification permission is required for background location tracking. Please enable notifications in app settings.')
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('PRODUCTION: Notification permission granted, starting foreground service')
|
||||
|
||||
// Create initial persistent notification
|
||||
await this.createInitialForegroundNotification()
|
||||
|
||||
// Set up notification refresh timer (less frequent to avoid system conflicts)
|
||||
this.notificationRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.updateForegroundNotification()
|
||||
} catch (refreshError) {
|
||||
console.error('PRODUCTION: Failed to refresh notification:', refreshError)
|
||||
}
|
||||
}, this.notificationRefreshInterval)
|
||||
|
||||
console.log('PRODUCTION: Foreground service started successfully with notification refresh')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to start foreground service:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial foreground notification
|
||||
*/
|
||||
async createInitialForegroundNotification() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('PRODUCTION: Creating initial foreground notification')
|
||||
|
||||
await LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
title: "Work Location Tracking",
|
||||
body: "Location tracking started for work attendance monitoring",
|
||||
id: 999,
|
||||
sound: null,
|
||||
smallIcon: "ic_stat_location_on",
|
||||
iconColor: "#0066CC",
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
extra: {
|
||||
priority: "high",
|
||||
category: "service",
|
||||
visibility: "public",
|
||||
showWhen: true,
|
||||
when: Date.now()
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
console.log('PRODUCTION: Initial foreground notification created successfully')
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to create initial foreground notification:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update foreground notification with current status
|
||||
*/
|
||||
async updateForegroundNotification() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const uptime = this.serviceStartTime ? Math.floor((Date.now() - this.serviceStartTime) / 60000) : 0
|
||||
const nextUpdate = new Date(Date.now() + this.locationUpdateInterval).toLocaleTimeString()
|
||||
|
||||
// Cancel existing notification first to avoid conflicts
|
||||
await LocalNotifications.cancel({
|
||||
notifications: [{ id: 999 }]
|
||||
})
|
||||
|
||||
// Wait a moment before creating new notification
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
await LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
title: "Work Location Tracking",
|
||||
body: `Active for ${uptime} min - Next update: ${nextUpdate}`,
|
||||
id: 999,
|
||||
sound: null,
|
||||
smallIcon: "ic_stat_location_on",
|
||||
iconColor: "#0066CC",
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
extra: {
|
||||
priority: "high",
|
||||
category: "service",
|
||||
visibility: "public",
|
||||
showWhen: true,
|
||||
when: Date.now()
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('PRODUCTION: Failed to update foreground notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh foreground notification to keep service alive (legacy method)
|
||||
*/
|
||||
async refreshForegroundNotification() {
|
||||
return this.updateForegroundNotification()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop foreground service
|
||||
*/
|
||||
async stopForegroundService() {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop the notification refresh timer
|
||||
if (this.notificationRefreshTimer) {
|
||||
clearInterval(this.notificationRefreshTimer)
|
||||
this.notificationRefreshTimer = null
|
||||
}
|
||||
|
||||
// Cancel the persistent notification
|
||||
await LocalNotifications.cancel({
|
||||
notifications: [{ id: 999 }]
|
||||
})
|
||||
console.log('Foreground service stopped')
|
||||
} catch (error) {
|
||||
console.error('Failed to stop foreground service:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last location update timestamp
|
||||
*/
|
||||
|
||||
@@ -215,8 +215,12 @@ class NativeServicesManager {
|
||||
console.log('App going to background - ensuring services continue')
|
||||
|
||||
try {
|
||||
// Ensure location tracking continues in background
|
||||
if (this.isNative && !this.services.location.isLocationTrackingActive()) {
|
||||
// Notify location service about background state
|
||||
this.services.location.setBackgroundState(true)
|
||||
|
||||
// Ensure location tracking continues in background only if worker is clocked in
|
||||
if (this.isNative && this.services.location.getClockedInStatus() && !this.services.location.isLocationTrackingActive()) {
|
||||
console.log('Worker is clocked in, restarting location tracking for background')
|
||||
await this.services.location.startTracking()
|
||||
}
|
||||
|
||||
@@ -231,15 +235,28 @@ class NativeServicesManager {
|
||||
* Handle app coming to foreground
|
||||
*/
|
||||
async onAppForeground() {
|
||||
console.log('App coming to foreground - checking service status')
|
||||
console.log('OPTIMIZED: App coming to foreground - checking service status')
|
||||
|
||||
try {
|
||||
// Check if services are still running
|
||||
if (this.isNative && !this.services.location.isLocationTrackingActive()) {
|
||||
console.log('Location tracking stopped, restarting...')
|
||||
// Notify location service about foreground state
|
||||
this.services.location.setBackgroundState(false)
|
||||
|
||||
// Check if services are still running - only restart if worker is clocked in
|
||||
if (this.isNative && this.services.location.getClockedInStatus() && !this.services.location.isLocationTrackingActive()) {
|
||||
console.log('OPTIMIZED: Worker is clocked in but location tracking stopped, restarting...')
|
||||
await this.services.location.startTracking()
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Perform geofence check when app resumes (if worker is clocked in)
|
||||
if (this.isNative && this.services.location.getClockedInStatus()) {
|
||||
console.log('OPTIMIZED: Performing geofence check on app resume')
|
||||
try {
|
||||
await this.services.location.getCurrentLocationAndSend({ checkGeofence: true })
|
||||
} catch (locationError) {
|
||||
console.error('OPTIMIZED: Failed to perform geofence check on app resume:', locationError)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry any pending operations
|
||||
await this.services.location.retryPendingLocationUpdates()
|
||||
await this.services.antiSpoofing.retryPendingSecurityChecks()
|
||||
@@ -247,7 +264,7 @@ class NativeServicesManager {
|
||||
// Send heartbeat
|
||||
await this.services.deviceUuid.sendDeviceHeartbeat()
|
||||
} catch (error) {
|
||||
console.error('Failed to handle app foreground:', error)
|
||||
console.error('OPTIMIZED: Failed to handle app foreground:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="mobile-viewport bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('changePasswordTitle') }}</h1>
|
||||
<header class="fixed left-0 right-0 top-0 z-50 bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6" style="padding-top: calc(var(--safe-area-inset-top) + 1.5rem);">
|
||||
<div class="flex items-center">
|
||||
<button @click="goBack" class="mr-4 p-2 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
<ArrowLeftIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold">{{ $t('changePasswordTitle') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-lg mx-auto">
|
||||
<main class="main-with-fixed-header-and-nav px-4 py-8">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-lg mx-auto mt-8">
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-6">
|
||||
<!-- Success Message -->
|
||||
<div v-if="successMessage" class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg">
|
||||
@@ -49,10 +54,11 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { apiFetch } from '@/api.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const passwords = ref({
|
||||
currentPassword: '',
|
||||
@@ -104,4 +110,9 @@ const handleChangePassword = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex justify-center items-center min-h-screen bg-gray-100">
|
||||
<div class="w-full max-w-sm p-8 space-y-6 bg-white rounded-2xl shadow-lg">
|
||||
<div class="flex justify-center items-center mobile-viewport bg-gray-100 safe-top safe-bottom">
|
||||
<div class="w-full max-w-sm p-8 space-y-3 bg-white rounded-2xl shadow-lg">
|
||||
<!-- App Logo -->
|
||||
<div class="flex justify-center">
|
||||
<ArrowRightOnRectangleIcon class="w-16 h-16 text-blue-600" />
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">
|
||||
{{ $t('servicesStatus') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Overall Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('overallStatus') }}</span>
|
||||
<span :class="overallStatusClass">
|
||||
{{ overallStatusText }}
|
||||
</span>
|
||||
<div class="mobile-viewport bg-gray-100 flex flex-col overflow-hidden">
|
||||
<!-- Fixed Header -->
|
||||
<header class="fixed left-0 right-0 top-0 z-50 bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6" style="padding-top: calc(var(--safe-area-inset-top) + 1.5rem);">
|
||||
<div class="flex items-center">
|
||||
<button @click="goBack" class="mr-4 p-2 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
<ArrowLeftIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold">{{ $t('servicesStatus') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Location Tracking -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Scrollable Main Content -->
|
||||
<main class="px-4 py-8 space-y-8 mt-[calc(100px+env(safe-area-inset-top))]">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 mt-8">
|
||||
<div class="space-y-2">
|
||||
<!-- Overall Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('overallStatus') }}</span>
|
||||
<span :class="overallStatusClass">
|
||||
{{ overallStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location Tracking -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('locationTracking') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="locationStatusClass">
|
||||
{{ locationStatusText }}
|
||||
</span>
|
||||
<button
|
||||
<button
|
||||
v-if="isNative && !serviceStatus.services?.location?.tracking"
|
||||
@click="startLocationTracking"
|
||||
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600"
|
||||
@@ -45,7 +56,7 @@
|
||||
<span :class="securityStatusClass">
|
||||
{{ securityStatusText }}
|
||||
</span>
|
||||
<button
|
||||
<button
|
||||
@click="runSecurityCheck"
|
||||
:disabled="securityCheckRunning"
|
||||
class="text-xs bg-green-500 text-white px-2 py-1 rounded hover:bg-green-600 disabled:opacity-50"
|
||||
@@ -72,36 +83,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
@click="refreshStatus"
|
||||
:disabled="refreshing"
|
||||
class="text-sm bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{{ refreshing ? $t('refreshing') : $t('refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Refresh Button -->
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
@click="refreshStatus"
|
||||
:disabled="refreshing"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-xl hover:bg-blue-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{{ refreshing ? $t('refreshing') : $t('refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div v-if="errorMessage" class="mt-3 p-2 bg-red-100 text-red-700 rounded text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<!-- Error Messages -->
|
||||
<div v-if="errorMessage" class="mt-4 p-4 bg-red-100 text-red-700 rounded-xl text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success Messages -->
|
||||
<div v-if="successMessage" class="mt-3 p-2 bg-green-100 text-green-700 rounded text-sm">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
<!-- Success Messages -->
|
||||
<div v-if="successMessage" class="mt-4 p-4 bg-green-100 text-green-700 rounded-xl text-sm">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const serviceStatus = ref({})
|
||||
const refreshing = ref(false)
|
||||
@@ -146,7 +162,7 @@ const deviceStatusText = computed(() => {
|
||||
const securityStatusClass = computed(() => {
|
||||
const lastCheck = serviceStatus.value.services?.antiSpoofing?.lastCheck
|
||||
if (!lastCheck) return 'text-yellow-500 text-sm'
|
||||
|
||||
|
||||
const hoursSinceCheck = (Date.now() - new Date(lastCheck).getTime()) / (1000 * 60 * 60)
|
||||
if (hoursSinceCheck > 24) return 'text-yellow-500 text-sm'
|
||||
return 'text-green-500 text-sm'
|
||||
@@ -155,7 +171,7 @@ const securityStatusClass = computed(() => {
|
||||
const securityStatusText = computed(() => {
|
||||
const lastCheck = serviceStatus.value.services?.antiSpoofing?.lastCheck
|
||||
if (!lastCheck) return t('notChecked')
|
||||
|
||||
|
||||
const hoursSinceCheck = (Date.now() - new Date(lastCheck).getTime()) / (1000 * 60 * 60)
|
||||
if (hoursSinceCheck > 24) return t('outdated')
|
||||
return t('current')
|
||||
@@ -165,7 +181,7 @@ const securityStatusText = computed(() => {
|
||||
const refreshStatus = async () => {
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
|
||||
try {
|
||||
serviceStatus.value = nativeServicesManager.getServiceStatus()
|
||||
} catch (error) {
|
||||
@@ -180,7 +196,7 @@ const startLocationTracking = async () => {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
|
||||
await nativeServicesManager.startServices()
|
||||
successMessage.value = t('locationTrackingStarted')
|
||||
await refreshStatus()
|
||||
@@ -194,7 +210,7 @@ const runSecurityCheck = async () => {
|
||||
securityCheckRunning.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
|
||||
try {
|
||||
await nativeServicesManager.forceSecurityCheck()
|
||||
successMessage.value = t('securityCheckComplete')
|
||||
@@ -209,17 +225,17 @@ const runSecurityCheck = async () => {
|
||||
|
||||
const formatLastUpdate = (timestamp) => {
|
||||
if (!timestamp) return t('never')
|
||||
|
||||
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMinutes = Math.floor((now - date) / (1000 * 60))
|
||||
|
||||
|
||||
if (diffMinutes < 1) return t('justNow')
|
||||
if (diffMinutes < 60) return t('minutesAgo', { minutes: diffMinutes })
|
||||
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return t('hoursAgo', { hours: diffHours })
|
||||
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return t('daysAgo', { days: diffDays })
|
||||
}
|
||||
@@ -229,13 +245,17 @@ const clearMessages = () => {
|
||||
successMessage.value = ''
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await refreshStatus()
|
||||
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(refreshStatus, 30000)
|
||||
|
||||
|
||||
// Clear messages after 5 seconds
|
||||
setInterval(clearMessages, 5000)
|
||||
})
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="mobile-viewport bg-gray-100">
|
||||
<!-- Fixed Header -->
|
||||
<header class="fixed-header-safe bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('personal') }}</h1>
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('setting') }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8 space-y-6">
|
||||
<!-- Scrollable Main Content -->
|
||||
<main class="main-with-fixed-header-and-nav px-4 py-8 space-y-4">
|
||||
<!-- Menu Items -->
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden mt-8">
|
||||
<!-- Clock History -->
|
||||
<router-link to="/worker/history" class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mr-5">
|
||||
@@ -23,18 +24,16 @@
|
||||
</router-link>
|
||||
|
||||
<!-- Service Status -->
|
||||
<div class="p-5 border-b border-gray-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<CogIcon class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg text-gray-900">{{ $t('servicesStatus') }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ $t('systemServicesStatus') }}</p>
|
||||
</div>
|
||||
<router-link to="/worker/services-status" class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<CogIcon class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<NativeServicesStatus />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg text-gray-900">{{ $t('servicesStatus') }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ $t('systemServicesStatus') }}</p>
|
||||
</div>
|
||||
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
|
||||
</router-link>
|
||||
|
||||
<!-- Change Password -->
|
||||
<router-link to="/worker/change-password" class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
@@ -66,7 +65,7 @@
|
||||
<!-- Logout Button -->
|
||||
<button @click="logout" class="w-full flex items-center justify-center p-5 bg-white rounded-2xl shadow-lg hover:bg-red-50 transition-colors">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<ArrowLeftOnRectangleIcon class="w-8 h-8 text-red-600" />
|
||||
<ArrowRightOnRectangleIcon class="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<div class="flex-grow text-left">
|
||||
<h3 class="font-semibold text-lg text-red-600">{{ $t('logout') }}</h3>
|
||||
@@ -81,12 +80,11 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChartBarIcon, CogIcon, LockClosedIcon, LanguageIcon, ArrowLeftOnRectangleIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
|
||||
import { ChartBarIcon, CogIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
|
||||
import { authService } from '@/services/authService.js'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
import NativeServicesStatus from '@/components/NativeServicesStatus.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const currentLang = ref(locale.value)
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="mobile-viewport bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<header class="fixed-header-safe bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('appTitle') }}</h1>
|
||||
<p class="text-center text-blue-200 mt-1">{{ workerName }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8 space-y-8">
|
||||
<main class="main-with-fixed-header px-4 py-8 space-y-8">
|
||||
<!-- Clock Status Card -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 text-center">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 text-center mt-16">
|
||||
<div class="flex items-center justify-center gap-4 mb-4">
|
||||
<component :is="isClockedIn ? CheckCircleIcon : ClockIcon"
|
||||
<component :is="isClockedIn ? CheckCircleIcon : ClockIcon"
|
||||
:class="['w-16 h-16', isClockedIn ? 'text-green-500' : 'text-red-500']" />
|
||||
</div>
|
||||
<p class="text-lg text-gray-600 mb-1">{{ $t('yourStatus') }}</p>
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="mobile-viewport bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('myClockHistory') }}</h1>
|
||||
<header class="fixed left-0 right-0 top-0 z-50 bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6" style="padding-top: calc(var(--safe-area-inset-top) + 1.5rem);">
|
||||
<div class="flex items-center">
|
||||
<button @click="goBack" class="mr-4 p-2 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
<ArrowLeftIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold">{{ $t('myClockHistory') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8">
|
||||
<main class="main-with-fixed-header-and-nav px-4 py-8">
|
||||
<!-- Empty State -->
|
||||
<div v-if="!clockHistory.length" class="text-center py-16">
|
||||
<div v-if="!clockHistory.length" class="text-center py-16 mt-8">
|
||||
<ChartBarIcon class="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 class="text-2xl font-semibold text-gray-700">{{ $t('noClockHistory') }}</h2>
|
||||
<p class="text-gray-500 mt-2">{{ $t('clockHistoryEmptyState') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="event in clockHistory" :key="event.id"
|
||||
<div v-else class="space-y-4 mt-8 mb-10">
|
||||
<div v-for="event in clockHistory" :key="event.id"
|
||||
class="bg-white rounded-2xl shadow-lg p-5 flex items-center space-x-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="event.event_type === 'clock_in' ? 'bg-green-100' : 'bg-red-100'">
|
||||
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon"
|
||||
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon"
|
||||
:class="['w-8 h-8', event.event_type === 'clock_in' ? 'text-green-600' : 'text-red-600']" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
@@ -42,7 +47,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChartBarIcon, ArrowDownCircleIcon, ArrowUpCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { ChartBarIcon, ArrowDownCircleIcon, ArrowUpCircleIcon,ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -64,6 +69,10 @@ onMounted(async () => {
|
||||
console.error(t('clockHistoryFetchFail'), error)
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user