feat(移动端优化): 实现安卓原生应用的全屏布局和安全区域处理

添加安全区域插件和样式处理,优化移动端视图布局
重构路由和导航结构,改进底部导航栏
增强原生功能集成,包括状态栏和导航栏控制
优化位置服务和后台任务处理
更新语言包和样式以适应移动端体验
This commit is contained in:
sudomarcma
2025-07-08 14:15:25 +08:00
parent 8428d03051
commit 2b4a7b2c50
26 changed files with 1215 additions and 209 deletions
+2
View File
@@ -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')
+3 -1
View File
@@ -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>
+6
View File
@@ -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`;
+55 -31
View File
@@ -189,18 +189,28 @@ async function startServer() {
}
// Define the geofence polygon by calling the 'polygon' function directly
const geofence = polygon([
// 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],
],
])
[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', '')
// 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 {
// Fallback to current time for invalid formats
mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '')
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' })
+14
View File
@@ -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
View File
@@ -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>
+20
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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>
+99
View File
@@ -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 */
}
}
+75 -15
View File
@@ -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>
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+25 -11
View File
@@ -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 {
+29 -1
View File
@@ -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,
@@ -259,6 +264,9 @@ class AuthService {
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) {
sessionStorage.removeItem('token')
@@ -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) {
+504 -37
View File
@@ -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
*/
+24 -7
View File
@@ -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)
}
}
+19 -8
View File
@@ -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>
+2 -2
View File
@@ -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,9 +1,20 @@
<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="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>
<!-- 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">
@@ -73,35 +84,40 @@
</div>
<!-- Refresh Button -->
<div class="mt-4 flex justify-end">
<div class="mt-6 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"
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">
<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">
<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)
@@ -229,6 +245,10 @@ const clearMessages = () => {
successMessage.value = ''
}
const goBack = () => {
router.back()
}
// Lifecycle
onMounted(async () => {
await refreshStatus()
@@ -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">
<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>
<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>
</div>
<NativeServicesStatus />
</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)
+4 -4
View File
@@ -1,16 +1,16 @@
<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"
:class="['w-16 h-16', isClockedIn ? 'text-green-500' : 'text-red-500']" />
+17 -8
View File
@@ -1,22 +1,27 @@
<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-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"
@@ -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>