feat(主题): 添加暗黑模式支持并改进WebView兼容性

实现暗黑模式功能,包括:
1. 在Tailwind配置中添加darkMode选项
2. 为所有主要组件添加暗黑样式
3. 创建WebView兼容性工具处理旧版本兼容问题
4. 在设置页面添加暗黑模式切换开关
5. 更新多语言文件支持暗黑模式相关文本
This commit is contained in:
sudomarcma
2025-07-24 10:23:57 +08:00
parent 44c7ea552f
commit bf90ff714c
24 changed files with 505 additions and 103 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
node_modules
node_modules
dist
-58
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{W as w}from"./index-B7lrTY2m.js";class y extends w{async getId(){return{identifier:this.getUid()}}async getInfo(){if(typeof navigator>"u"||!navigator.userAgent)throw this.unavailable("Device API not available in this browser");const e=navigator.userAgent,i=this.parseUa(e);return{model:i.model,platform:"web",operatingSystem:i.operatingSystem,osVersion:i.osVersion,manufacturer:navigator.vendor,isVirtual:!1,webViewVersion:i.browserVersion}}async getBatteryInfo(){if(typeof navigator>"u"||!navigator.getBattery)throw this.unavailable("Device API not available in this browser");let e={};try{e=await navigator.getBattery()}catch{}return{batteryLevel:e.level,isCharging:e.charging}}async getLanguageCode(){return{value:navigator.language.split("-")[0].toLowerCase()}}async getLanguageTag(){return{value:navigator.language}}parseUa(e){const i={},r=e.indexOf("(")+1;let a=e.indexOf(") AppleWebKit");e.indexOf(") Gecko")!==-1&&(a=e.indexOf(") Gecko"));const s=e.substring(r,a);if(e.indexOf("Android")!==-1){const t=s.replace("; wv","").split("; ").pop();t&&(i.model=t.split(" Build")[0]),i.osVersion=s.split("; ")[1]}else if(i.model=s.split("; ")[0],typeof navigator<"u"&&navigator.oscpu)i.osVersion=navigator.oscpu;else if(e.indexOf("Windows")!==-1)i.osVersion=s;else{const t=s.split("; ").pop();if(t){const n=t.replace(" like Mac OS X","").split(" ");i.osVersion=n[n.length-1].replace(/_/g,".")}}/android/i.test(e)?i.operatingSystem="android":/iPad|iPhone|iPod/.test(e)&&!window.MSStream?i.operatingSystem="ios":/Win/.test(e)?i.operatingSystem="windows":/Mac/i.test(e)?i.operatingSystem="mac":i.operatingSystem="unknown";const l=!!window.ApplePaySession,x=!!window.chrome,p=/Firefox/.test(e),d=/Edg/.test(e),g=/FxiOS/.test(e),c=/CriOS/.test(e),f=/EdgiOS/.test(e);if(l||x&&!d||g||c||f){let t;g?t="FxiOS":c?t="CriOS":f?t="EdgiOS":l?t="Version":t="Chrome";const n=e.split(" ");for(const o of n)if(o.includes(t)){const v=o.split("/")[1];i.browserVersion=v}}else if(p||d){const o=e.split("").reverse().join("").split("/")[0].split("").reverse().join("");i.browserVersion=o}return i}getUid(){if(typeof window<"u"&&window.localStorage){let e=window.localStorage.getItem("_capuid");return e||(e=this.uuid4(),window.localStorage.setItem("_capuid",e),e)}return this.uuid4()}uuid4(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(e){const i=Math.random()*16|0;return(e==="x"?i:i&3|8).toString(16)})}}export{y as DeviceWeb};
-1
View File
@@ -1 +0,0 @@
import{W as p}from"./index-B7lrTY2m.js";class f extends p{constructor(){super(...arguments),this.group="CapacitorStorage"}async configure({group:e}){typeof e=="string"&&(this.group=e)}async get(e){return{value:this.impl.getItem(this.applyPrefix(e.key))}}async set(e){this.impl.setItem(this.applyPrefix(e.key),e.value)}async remove(e){this.impl.removeItem(this.applyPrefix(e.key))}async keys(){return{keys:this.rawKeys().map(t=>t.substring(this.prefix.length))}}async clear(){for(const e of this.rawKeys())this.impl.removeItem(e)}async migrate(){var e;const t=[],s=[],n="_cap_",o=Object.keys(this.impl).filter(i=>i.indexOf(n)===0);for(const i of o){const r=i.substring(n.length),a=(e=this.impl.getItem(i))!==null&&e!==void 0?e:"",{value:l}=await this.get({key:r});typeof l=="string"?s.push(r):(await this.set({key:r,value:a}),t.push(r))}return{migrated:t,existing:s}}async removeOld(){const e="_cap_",t=Object.keys(this.impl).filter(s=>s.indexOf(e)===0);for(const s of t)this.impl.removeItem(s)}get impl(){return window.localStorage}get prefix(){return this.group==="NativeStorage"?"":`${this.group}.`}rawKeys(){return Object.keys(this.impl).filter(e=>e.indexOf(this.prefix)===0)}applyPrefix(e){return this.prefix+e}}export{f as PreferencesWeb};
-1
View File
@@ -1 +0,0 @@
import{W as i}from"./index-B7lrTY2m.js";function o(){const t=window.navigator.connection||window.navigator.mozConnection||window.navigator.webkitConnection;let n="unknown";const e=t?t.type||t.effectiveType:null;if(e&&typeof e=="string")switch(e){case"bluetooth":case"cellular":n="cellular";break;case"none":n="none";break;case"ethernet":case"wifi":case"wimax":n="wifi";break;case"other":case"unknown":n="unknown";break;case"slow-2g":case"2g":case"3g":n="cellular";break;case"4g":n="wifi";break}return n}class s extends i{constructor(){super(),this.handleOnline=()=>{const e={connected:!0,connectionType:o()};this.notifyListeners("networkStatusChange",e)},this.handleOffline=()=>{const n={connected:!1,connectionType:"none"};this.notifyListeners("networkStatusChange",n)},typeof window<"u"&&(window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline))}async getStatus(){if(!window.navigator)throw this.unavailable("Browser does not support the Network Information API");const n=window.navigator.onLine,e=o();return{connected:n,connectionType:n?e:"none"}}}const r=new s;export{r as Network,s as NetworkWeb};
-1
View File
@@ -1 +0,0 @@
import{W as s}from"./index-B7lrTY2m.js";class c extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i.name=="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{c as LocalNotificationsWeb};
-1
View File
@@ -1 +0,0 @@
import{W as n}from"./index-B7lrTY2m.js";class r extends n{async enable(e){}async disable(e){}}export{r as SafeAreaWeb};
-1
View File
@@ -1 +0,0 @@
import{W as t}from"./index-B7lrTY2m.js";class s extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}}export{s as AppWeb};
-1
View File
@@ -1 +0,0 @@
import{W as o}from"./index-B7lrTY2m.js";class a extends o{async getCurrentPosition(e){return new Promise((t,n)=>{navigator.geolocation.getCurrentPosition(i=>{t(i)},i=>{n(i)},Object.assign({enableHighAccuracy:!1,timeout:1e4,maximumAge:0},e))})}async watchPosition(e,t){return`${navigator.geolocation.watchPosition(i=>{t(i)},i=>{t(null,i)},Object.assign({enableHighAccuracy:!1,timeout:1e4,maximumAge:0,minimumUpdateInterval:5e3},e))}`}async clearWatch(e){navigator.geolocation.clearWatch(parseInt(e.id,10))}async checkPermissions(){if(typeof navigator>"u"||!navigator.permissions)throw this.unavailable("Permissions API not available in this browser");const e=await navigator.permissions.query({name:"geolocation"});return{location:e.state,coarseLocation:e.state}}async requestPermissions(){throw this.unimplemented("Not implemented on web.")}}const c=new a;export{c as Geolocation,a as GeolocationWeb};
+2 -2
View File
@@ -8,8 +8,8 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-B7lrTY2m.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnygOJsq.css">
<script type="module" crossorigin src="/assets/index-DHS6QlZu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-PWd6qU--.css">
</head>
<body>
<div id="app"></div>
+26 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-gray-100 text-gray-900">
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
<!-- App Blocker Overlay -->
<div v-if="isBlocked" class="blocker-overlay">
<div class="blocker-content">
@@ -15,6 +15,8 @@
<!-- Bottom Navigation (only show for worker routes) -->
<BottomNavigation v-if="showBottomNav && !isBlocked" />
</div>
</template>
@@ -28,6 +30,7 @@ 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'
import { initWebViewCompatibility, applyThemeWithCompat, getSystemDarkModePreference } from '@/utils/webviewCompat'
const { locale } = useI18n()
@@ -37,7 +40,26 @@ const route = useRoute()
const isLoggedIn = ref(false)
const isBlocked = ref(false)
const blockMessage = ref('')
// Theme initialization with WebView compatibility
const updateTheme = (isDark) => {
applyThemeWithCompat(isDark)
}
const initializeTheme = () => {
// Initialize WebView compatibility first
initWebViewCompatibility()
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme')
let isDark = false
if (savedTheme) {
isDark = savedTheme === 'dark'
} else {
// Check system preference with WebView compatibility
isDark = getSystemDarkModePreference()
}
updateTheme(isDark)
}
// Show bottom navigation only for worker routes
const showBottomNav = computed(() => {
@@ -112,6 +134,9 @@ watch(
)
onMounted(async () => {
// Initialize theme
initializeTheme()
// Add app blocked event listener
window.addEventListener('app-blocked', handleAppBlocked)
window.addEventListener('user-forced-clock-out', handleForcedClockOut)
+197
View File
@@ -106,4 +106,201 @@
html.dark {
color-scheme: dark;
}
/* Smooth transitions for theme changes */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Dark mode scrollbar styling */
html.dark ::-webkit-scrollbar {
width: 8px;
}
html.dark ::-webkit-scrollbar-track {
background: #374151;
}
html.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
}
/* WebView Compatibility Styles */
@layer utilities {
/* Force specific styles for older WebView versions */
.webview-compat {
/* Background colors */
--bg-color: #ffffff;
--text-color: #000000;
--card-bg: #ffffff;
--gray-bg: #f3f4f6;
--text-gray: #6b7280;
--border-color: #e5e7eb;
--nav-bg: #ffffff;
--nav-border: #e5e7eb;
}
.webview-compat.dark {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--card-bg: #2d2d2d;
--gray-bg: #1f2937;
--text-gray: #d1d5db;
--border-color: #404040;
--nav-bg: #1f2937;
--nav-border: #374151;
}
/* Apply fallback styles for older WebView */
.webview-compat .bg-white {
background-color: #ffffff;
}
.webview-compat.dark .bg-white {
background-color: #2d2d2d !important;
}
.webview-compat .bg-gray-100 {
background-color: #f3f4f6;
}
.webview-compat.dark .bg-gray-100 {
background-color: #1a1a1a !important;
}
.webview-compat .bg-gray-800 {
background-color: #1f2937;
}
.webview-compat.dark .bg-gray-800 {
background-color: #2d2d2d !important;
}
/* Navigation bar specific styles for WebView compatibility */
.webview-compat .bottom-nav-content {
background-color: #ffffff !important;
border-top: 1px solid #e5e7eb !important;
}
.webview-compat.dark .bottom-nav-content {
background-color: #1f2937 !important;
border-top: 1px solid #374151 !important;
color: #f9fafb !important;
}
/* Text colors */
.webview-compat .text-gray-600 {
color: #4b5563;
}
.webview-compat.dark .text-gray-600 {
color: #d1d5db !important;
}
.webview-compat .text-gray-800 {
color: #1f2937;
}
.webview-compat.dark .text-gray-800 {
color: #f9fafb !important;
}
/* Worker dashboard specific styles */
.webview-compat .text-gray-900 {
color: #111827;
}
.webview-compat.dark .text-gray-900 {
color: #f9fafb !important;
}
.webview-compat .text-gray-400 {
color: #9ca3af;
}
.webview-compat.dark .text-gray-400 {
color: #d1d5db !important;
}
/* Navigation bar text styles */
.webview-compat .bottom-nav-content .text-gray-600 {
color: #4b5563;
}
.webview-compat.dark .bottom-nav-content .text-gray-600 {
color: #d1d5db !important;
}
.webview-compat .bottom-nav-content .text-blue-600 {
color: #2563eb;
}
.webview-compat.dark .bottom-nav-content .text-blue-600 {
color: #60a5fa !important;
}
.webview-compat .bottom-nav-content span {
color: inherit;
}
.webview-compat.dark .bottom-nav-content span {
color: #d1d5db !important;
}
/* Border colors
.webview-compat .border-gray-200 {
border-color: #e5e7eb;
}
.webview-compat.dark .border-gray-200 {
border-color: #404040 !important;
}
/* Ensure transitions work in older WebView */
.webview-compat * {
-webkit-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
}
/* CSS Variables with fallbacks for older WebView */
:root {
--bg-color: #ffffff;
--text-color: #000000;
--card-bg: #ffffff;
--border-color: #e5e7eb;
}
html.dark {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--card-bg: #2d2d2d;
--border-color: #404040;
}
/* Apply CSS variables with fallbacks */
.theme-bg {
background-color: #ffffff; /* fallback */
background-color: var(--bg-color, #ffffff);
}
.theme-text {
color: #000000; /* fallback */
color: var(--text-color, #000000);
}
.theme-card {
background-color: #ffffff; /* fallback */
background-color: var(--card-bg, #ffffff);
}
.theme-border {
border-color: #e5e7eb; /* fallback */
border-color: var(--border-color, #e5e7eb);
}
+9 -2
View File
@@ -5,7 +5,7 @@
<router-link
to="/worker/dashboard"
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
:class="isClockInActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
:class="isClockInActive ? 'text-blue-600' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700'"
>
<component :is="isClockInActive ? ClockIconSolid : ClockIconOutline" class="w-7 h-7 mb-1" />
<span class="text-xs font-medium">{{ $t('clockIn') }}</span>
@@ -15,7 +15,7 @@
<router-link
to="/worker/settings"
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
:class="isSettingsActive ? '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 dark:bg-blue-900/50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700'"
>
<component :is="isSettingsActive ? SettingsIconSolid : SettingsIconOutline" class="w-7 h-7 mb-1" />
<span class="text-xs font-medium">{{ $t('setting') }}</span>
@@ -73,6 +73,13 @@ const isSettingsActive = computed(() =>
user-select: none;
}
/* Dark mode styles */
html.dark .bottom-nav-content {
background-color: #1f2937;
border-top: 1px solid #374151;
color: #f9fafb;
}
/* Prevent any scroll-related movement */
.bottom-nav-container::before {
content: '';
+2
View File
@@ -6,6 +6,8 @@
"password": "পাসওয়ার্ড",
"loggingIn": "লগ ইন করা হচ্ছে...",
"language": "ভাষা",
"darkMode": "ডার্ক মোড",
"toggleDarkMode": "হালকা এবং অন্ধকার থিমের মধ্যে পরিবর্তন করুন",
"failedConnection": "সার্ভারের সাথে সংযোগ করতে পারেনি।",
"invalidToken": "সার্ভার থেকে অবৈধ টোকেন পাওয়া গেছে।",
"invalidCredentials": "ভুল ইউজারনেম বা পাসওয়ার্ড।",
+2
View File
@@ -6,6 +6,8 @@
"password": "Password",
"loggingIn": "Logging in...",
"language": "Language",
"darkMode": "Dark Mode",
"toggleDarkMode": "Switch between light and dark themes",
"failedConnection": "Failed to connect to the server.",
"invalidToken": "Invalid token received from server.",
"invalidCredentials": "Invalid username or password.",
+2
View File
@@ -6,6 +6,8 @@
"password": "Kata Laluan",
"loggingIn": "Sedang log masuk...",
"language": "Bahasa",
"darkMode": "Mod Gelap",
"toggleDarkMode": "Tukar antara tema terang dan gelap",
"failedConnection": "Gagal untuk berhubung dengan pelayan.",
"invalidCredentials": "Nama pengguna atau kata laluan tidak sah.",
"invalidToken": "Token tidak sah diterima dari pelayan.",
+2
View File
@@ -6,6 +6,8 @@
"password": "လျှို့ဝှက်နံပါတ်",
"loggingIn": "ဝင်ရောက်နေသည်...",
"language": "ဘာသာစကား",
"darkMode": "မှောင်မိုက်မုဒ်",
"toggleDarkMode": "အလင်းနှင့် မှောင်မိုက်အပြင်အဆင်များကြား ပြောင်းလဲရန်",
"failedConnection": "ဆာဗာနှင့် ချိတ်ဆက်၍မရပါ။",
"invalidToken": "ဆာဗာမှ မမှန်ကန်သော တိုကင်ရရှိခဲ့သည်။",
"invalidCredentials": "အသုံးပြုသူအမည် သို့မဟုတ် လျှို့ဝှက်နံပါတ် မမှန်ကန်ပါ။",
+2
View File
@@ -6,6 +6,8 @@
"password": "पासवर्ड",
"loggingIn": "लगइन गर्दै...",
"language": "भाषा",
"darkMode": "डार्क मोड",
"toggleDarkMode": "उज्यालो र अँध्यारो थिमहरू बीच स्विच गर्नुहोस्",
"failedConnection": "सर्भरसँग जडान गर्न सकिएन।",
"invalidToken": "सर्भरबाट अमान्य टोकन प्राप्त भयो।",
"invalidCredentials": "गलत प्रयोगकर्ता नाम वा पासवर्ड।",
+2
View File
@@ -6,6 +6,8 @@
"password": "கடவுச்சொல்",
"loggingIn": "உள்நுழைகிறது...",
"language": "மொழி",
"darkMode": "இருண்ட பயன்முறை",
"toggleDarkMode": "வெளிச்சம் மற்றும் இருண்ட தீம்களுக்கு இடையில் மாற்றவும்",
"failedConnection": "சர்வருடன் இணைக்க முடியவில்லை.",
"invalidToken": "சர்வரிலிருந்து தவறான டோக்கன் பெறப்பட்டது.",
"invalidCredentials": "தவறான பயனர் பெயர் அல்லது கடவுச்சொல்.",
+180
View File
@@ -0,0 +1,180 @@
/**
* WebView Compatibility Utilities
* Provides compatibility handling for older WebView versions
*/
/**
* Detect WebView version and capabilities
*/
export const detectWebViewCapabilities = () => {
const capabilities = {
cssVariables: true,
matchMedia: true,
modernCSS: true,
version: null
}
try {
// Check for CSS Variables support
if (!CSS || !CSS.supports || !CSS.supports('color', 'var(--test)')) {
capabilities.cssVariables = false
}
} catch (error) {
capabilities.cssVariables = false
}
try {
// Check for matchMedia support
if (!window.matchMedia || typeof window.matchMedia !== 'function') {
capabilities.matchMedia = false
}
} catch (error) {
capabilities.matchMedia = false
}
try {
// Try to detect WebView version from user agent
const userAgent = navigator.userAgent
const chromeMatch = userAgent.match(/Chrome\/(\d+\.\d+\.\d+\.\d+)/)
if (chromeMatch) {
capabilities.version = chromeMatch[1]
const majorVersion = parseInt(chromeMatch[1].split('.')[0])
// Chrome 88+ has better CSS support
if (majorVersion < 88) {
capabilities.modernCSS = false
}
}
} catch (error) {
console.warn('Could not detect WebView version:', error)
}
return capabilities
}
/**
* Apply WebView compatibility class to document
*/
export const applyWebViewCompatibility = () => {
const capabilities = detectWebViewCapabilities()
// Add webview-compat class if needed
if (!capabilities.cssVariables || !capabilities.modernCSS) {
document.documentElement.classList.add('webview-compat')
console.info('Applied WebView compatibility mode')
}
return capabilities
}
/**
* Enhanced theme application with WebView compatibility
*/
export const applyThemeWithCompat = (isDark) => {
// --- FINAL FIX: Delay execution to prevent a race condition with Vue's rendering cycle. ---
setTimeout(() => {
const capabilities = detectWebViewCapabilities()
try {
if (isDark) {
document.documentElement.classList.add('dark')
if (!capabilities.cssVariables || !capabilities.modernCSS) {
document.documentElement.style.backgroundColor = '#1a1a1a';
document.documentElement.style.color = '#ffffff';
document.body.style.backgroundColor = '#1a1a1a';
document.body.style.color = '#ffffff';
const navElements = document.querySelectorAll('.bottom-nav-content');
navElements.forEach(el => {
el.style.setProperty('background-color', '#1f2937', 'important');
el.style.setProperty('border-top', '1px solid #374151', 'important');
el.style.setProperty('color', '#f9fafb', 'important');
});
const navInactiveElements = document.querySelectorAll('.bottom-nav-content .text-gray-600 span, .bottom-nav-content .text-gray-600 svg');
navInactiveElements.forEach(el => {
el.style.setProperty('color', '#d1d5db', 'important');
});
const navActiveElements = document.querySelectorAll('.bottom-nav-content .text-blue-600');
navActiveElements.forEach(el => {
el.style.setProperty('color', '#60a5fa', 'important');
el.style.setProperty('background-color', 'rgba(96, 165, 250, 0.15)', 'important');
});
}
} else {
document.documentElement.classList.remove('dark');
if (!capabilities.cssVariables || !capabilities.modernCSS) {
document.documentElement.style.backgroundColor = '#ffffff';
document.documentElement.style.color = '#000000';
document.body.style.backgroundColor = '#ffffff';
document.body.style.color = '#000000';
const navElements = document.querySelectorAll('.bottom-nav-content');
navElements.forEach(el => {
el.style.setProperty('background-color', '#ffffff', 'important');
el.style.setProperty('border-top', '1px solid #e5e7eb', 'important');
el.style.removeProperty('color');
});
const navInactiveElements = document.querySelectorAll('.bottom-nav-content .text-gray-600 span, .bottom-nav-content .text-gray-600 svg');
navInactiveElements.forEach(el => {
el.style.setProperty('color', '#4b5563', 'important');
});
const navActiveElements = document.querySelectorAll('.bottom-nav-content .text-blue-600');
navActiveElements.forEach(el => {
el.style.setProperty('color', '#2563eb', 'important');
el.style.setProperty('background-color', '#eff6ff', 'important');
});
}
}
} catch (error) {
console.warn('Error applying theme with compatibility:', error);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}, 100); // A 100ms delay to ensure Vue has rendered.
}
/**
* Check system dark mode preference with WebView compatibility
*/
export const getSystemDarkModePreference = () => {
try {
if (window.matchMedia && typeof window.matchMedia === 'function') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
} catch (error) {
console.warn('matchMedia not supported, defaulting to light mode')
}
return false
}
/**
* Initialize WebView compatibility on app startup
*/
export const initWebViewCompatibility = () => {
const capabilities = applyWebViewCompatibility()
console.info('WebView Capabilities:', capabilities)
if (!capabilities.cssVariables) {
console.warn('CSS Variables not supported, using fallback styles')
}
if (!capabilities.matchMedia) {
console.warn('matchMedia not supported, system theme detection disabled')
}
if (!capabilities.modernCSS) {
console.warn('Modern CSS features limited, using compatibility mode')
}
return capabilities
}
+8 -8
View File
@@ -1,13 +1,13 @@
<template>
<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">
<div class="flex justify-center items-center mobile-viewport bg-gray-100 dark:bg-gray-900 safe-top safe-bottom transition-colors duration-300">
<div class="w-full max-w-sm p-8 space-y-3 bg-white dark:bg-gray-800 rounded-2xl shadow-lg transition-colors duration-300">
<!-- App Logo -->
<div class="flex justify-center">
<ArrowRightOnRectangleIcon class="w-16 h-16 text-blue-600" />
<ArrowRightOnRectangleIcon class="w-16 h-16 text-blue-600 dark:text-blue-400" />
</div>
<!-- Title -->
<h2 class="text-3xl font-extrabold text-center text-gray-900">
<h2 class="text-3xl font-extrabold text-center text-gray-900 dark:text-gray-100">
{{ t('login') }}
</h2>
@@ -16,7 +16,7 @@
<div>
<label for="username" class="sr-only">{{ t('username') }}</label>
<input type="text" id="username" v-model="username" autocomplete="username"
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-300"
:placeholder="t('username')"
required />
</div>
@@ -25,7 +25,7 @@
<div>
<label for="password" class="sr-only">{{ t('password') }}</label>
<input type="password" id="password" v-model="password" autocomplete="current-password"
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-300"
:placeholder="t('password')"
required />
</div>
@@ -34,8 +34,8 @@
<div class="flex items-center justify-between">
<div class="flex items-center">
<input type="checkbox" id="rememberMe" v-model="rememberMe"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" />
<label for="rememberMe" class="ml-2 block text-sm text-gray-900">
class="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 dark:focus:ring-blue-400" />
<label for="rememberMe" class="ml-2 block text-sm text-gray-900 dark:text-gray-200">
{{ t('rememberMe') }}
</label>
</div>
+69 -23
View File
@@ -1,7 +1,7 @@
<template>
<div class="mobile-viewport bg-gray-100 min-h-screen">
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen transition-colors duration-300">
<!-- Fixed Header -->
<header class="fixed-header-safe bg-blue-600 text-white shadow-lg">
<header class="fixed-header-safe bg-blue-600 dark:bg-gray-800 text-white shadow-lg transition-colors duration-300">
<div class="px-4 py-6">
<h1 class="text-3xl font-bold text-center">{{ $t('setting') }}</h1>
</div>
@@ -10,42 +10,60 @@
<!-- Scrollable Main Content -->
<main class="main-with-fixed-header-and-nav px-4 py-8 space-y-4">
<!-- Menu Items -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden mt-8">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden mt-8 transition-colors duration-300">
<!-- Clock History -->
<router-link to="/worker/history"
class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mr-5">
<ChartBarIcon class="w-8 h-8 text-blue-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900">{{ $t('clockHistory') }}</h3>
<p class="text-sm text-gray-500">{{ $t('viewMyClockHistory') }}</p>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('clockHistory') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('viewMyClockHistory') }}</p>
</div>
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
<ChevronRightIcon class="w-6 h-6 text-gray-400 dark:text-gray-500" />
</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">
class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mr-5">
<LockClosedIcon class="w-8 h-8 text-orange-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900">{{ $t('changePassword') }}</h3>
<p class="text-sm text-gray-500">{{ $t('updateYourPassword') }}</p>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('changePassword') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('updateYourPassword') }}</p>
</div>
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
<ChevronRightIcon class="w-6 h-6 text-gray-400 dark:text-gray-500" />
</router-link>
<!-- Dark Mode Toggle -->
<div class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700">
<div class="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mr-5">
<MoonIcon v-if="!isDarkMode" class="w-8 h-8 text-indigo-600" />
<SunIcon v-else class="w-8 h-8 text-indigo-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('darkMode') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('toggleDarkMode') }}</p>
</div>
<button @click="toggleDarkMode"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="isDarkMode ? 'bg-indigo-600' : 'bg-gray-200'">
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="isDarkMode ? 'translate-x-6' : 'translate-x-1'"></span>
</button>
</div>
<!-- Language Selection -->
<div class="flex items-center p-5">
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mr-5">
<LanguageIcon class="w-8 h-8 text-purple-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900 mb-2">{{ $t('language') }}</h3>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100 mb-2">{{ $t('language') }}</h3>
<select v-model="currentLang" @change="changeLang"
class="w-full px-4 py-3 border border-gray-300 bg-white text-gray-900 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-300">
<option value="en">{{ $t('english') }}</option>
<option value="ms">{{ $t('malay') }}</option>
<option value="tm">{{ $t('tamil') }}</option>
@@ -58,29 +76,29 @@
</div>
<!-- App Information -->
<div class="bg-white rounded-2xl shadow-lg p-6">
<h3 class="font-semibold text-lg text-gray-900 mb-4">{{ $t('appInformation') }}</h3>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 transition-colors duration-300">
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100 mb-4">{{ $t('appInformation') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600">{{ $t('version') }}</span>
<span class="font-medium text-gray-900">1.0.0</span>
<span class="text-gray-600 dark:text-gray-400">{{ $t('version') }}</span>
<span class="font-medium text-gray-900 dark:text-gray-100">1.0.0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">{{ $t('platform') }}</span>
<span class="font-medium text-gray-900">{{ $t('android') }}</span>
<span class="text-gray-600 dark:text-gray-400">{{ $t('platform') }}</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $t('android') }}</span>
</div>
</div>
</div>
<!-- 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">
class="w-full flex items-center justify-center p-5 bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors duration-300">
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mr-5">
<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>
<p class="text-sm text-red-500">{{ $t('signOutOfAccount') }}</p>
<h3 class="font-semibold text-lg text-red-600 dark:text-red-400">{{ $t('logout') }}</h3>
<p class="text-sm text-red-500 dark:text-red-400">{{ $t('signOutOfAccount') }}</p>
</div>
</button>
</main>
@@ -91,20 +109,34 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ChartBarIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
import { ChartBarIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon, MoonIcon, SunIcon } from '@heroicons/vue/24/outline'
import { authService } from '@/services/authService.js'
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
import { applyThemeWithCompat, getSystemDarkModePreference } from '@/utils/webviewCompat.js'
const { locale } = useI18n()
const router = useRouter()
const currentLang = ref(locale.value)
const isDarkMode = ref(false)
onMounted(() => {
const savedLang = localStorage.getItem('lang')
if (savedLang) {
currentLang.value = savedLang
}
// Initialize dark mode with WebView compatibility
const savedTheme = localStorage.getItem('theme')
const prefersDark = getSystemDarkModePreference()
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
isDarkMode.value = true
applyThemeWithCompat(true)
} else {
isDarkMode.value = false
applyThemeWithCompat(false)
}
})
const changeLang = () => {
@@ -113,6 +145,20 @@ const currentLang = ref(locale.value)
}
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value
applyThemeWithCompat(isDarkMode.value)
if (isDarkMode.value) {
localStorage.setItem('theme', 'dark')
} else {
localStorage.setItem('theme', 'light')
}
}
const logout = async () => {
try {
await authService.logout()
+1
View File
@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},