From 05baaca23c371d831b7b5fe5eaa79700cda4554d Mon Sep 17 00:00:00 2001 From: rafaeldpsilva Date: Wed, 3 Sep 2025 17:07:19 +0100 Subject: [PATCH] Add settings page and store with UI customization options - Implement SettingsView with appearance, data, notifications, and advanced tabs - Add settings store (Pinia) for theme, navigation, notifications, and app config - Integrate settings store into HomeView and BottomNav for theme and navigation mode - Add room management modal and store methods for adding/removing rooms - Update SensorManagementView with room management button and modal - Support exporting/importing settings and resetting to defaults - Enable dark mode via Tailwind config --- src/components/common/BottomNav.vue | 56 +- src/components/modals/RoomManagementModal.vue | 299 ++++++++++ src/router/index.ts | 6 + src/stores/energy.ts | 68 ++- src/stores/settings.ts | 282 ++++++++++ src/views/HomeView.vue | 10 +- src/views/SensorManagementView.vue | 34 +- src/views/SettingsView.vue | 511 ++++++++++++++++++ tailwind.config.js | 1 + 9 files changed, 1250 insertions(+), 17 deletions(-) create mode 100644 src/components/modals/RoomManagementModal.vue create mode 100644 src/stores/settings.ts create mode 100644 src/views/SettingsView.vue diff --git a/src/components/common/BottomNav.vue b/src/components/common/BottomNav.vue index 86d152b..f5f88b8 100644 --- a/src/components/common/BottomNav.vue +++ b/src/components/common/BottomNav.vue @@ -1,10 +1,17 @@ - + diff --git a/src/components/modals/RoomManagementModal.vue b/src/components/modals/RoomManagementModal.vue new file mode 100644 index 0000000..f47830f --- /dev/null +++ b/src/components/modals/RoomManagementModal.vue @@ -0,0 +1,299 @@ + + + \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index 32d8f29..f972e8e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import SensorManagementView from '../views/SensorManagementView.vue' import AIOptimizationView from '../views/AIOptimizationView.vue' +import SettingsView from '../views/SettingsView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -21,6 +22,11 @@ const router = createRouter({ name: 'ai-optimization', component: AIOptimizationView, }, + { + path: '/settings', + name: 'settings', + component: SettingsView, + }, ], }) diff --git a/src/stores/energy.ts b/src/stores/energy.ts index 8653d60..d71f51a 100644 --- a/src/stores/energy.ts +++ b/src/stores/energy.ts @@ -425,6 +425,68 @@ export const useEnergyStore = defineStore('energy', () => { return Array.from(sensorDevices.values()).filter(sensor => sensor.type === type) } + // Room management functions + function addRoom(roomName: string): boolean { + if (!roomName.trim()) return false + + // Check if room already exists + if (availableRooms.value.includes(roomName.trim())) { + return false + } + + // Add room to available rooms list + availableRooms.value.push(roomName.trim()) + availableRooms.value.sort() // Keep rooms sorted alphabetically + + console.log(`Added new room: ${roomName}`) + return true + } + + function removeRoom(roomName: string): boolean { + const index = availableRooms.value.indexOf(roomName) + if (index === -1) return false + + // Check if any sensors are assigned to this room + const sensorsInRoom = Array.from(sensorDevices.values()).filter(sensor => sensor.room === roomName) + if (sensorsInRoom.length > 0) { + // Reassign sensors to 'Unassigned' + sensorsInRoom.forEach(sensor => { + sensor.room = '' + sensorDevices.set(sensor.id, { ...sensor }) + }) + } + + // Remove room data + roomsData.delete(roomName) + + // Remove from available rooms + availableRooms.value.splice(index, 1) + + console.log(`Removed room: ${roomName}`) + return true + } + + function getRoomStats(roomName: string) { + const sensorsInRoom = getSensorsByRoom(roomName) + const roomMetrics = roomsData.get(roomName) + + return { + sensorCount: sensorsInRoom.length, + sensorTypes: [...new Set(sensorsInRoom.map(s => s.type))], + hasMetrics: !!roomMetrics, + energyConsumption: roomMetrics?.energy.current || 0, + co2Level: roomMetrics?.co2.current || 0, + lastUpdated: roomMetrics?.lastUpdated || null + } + } + + function getAllRoomsWithStats() { + return availableRooms.value.map(room => ({ + name: room, + ...getRoomStats(room) + })) + } + // Initialize mock sensors on store creation initializeMockSensors() @@ -443,6 +505,10 @@ export const useEnergyStore = defineStore('energy', () => { updateSensorRoom, executeSensorAction, getSensorsByRoom, - getSensorsByType + getSensorsByType, + addRoom, + removeRoom, + getRoomStats, + getAllRoomsWithStats } }) diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 0000000..ca8a560 --- /dev/null +++ b/src/stores/settings.ts @@ -0,0 +1,282 @@ +import { defineStore } from 'pinia' +import { ref, reactive, watch } from 'vue' + +export type NavigationMode = 'hover' | 'always' | 'hidden' +export type Theme = 'light' | 'dark' | 'system' +export type Language = 'en' | 'es' | 'fr' | 'de' + +interface NotificationSettings { + enabled: boolean + sound: boolean + desktop: boolean + email: boolean + criticalOnly: boolean +} + +interface UISettings { + navigationMode: NavigationMode + compactMode: boolean + showAnimations: boolean + autoRefresh: boolean + refreshInterval: number // seconds + dateFormat: 'relative' | 'absolute' + timeFormat: '12h' | '24h' +} + +interface AppSettings { + theme: Theme + language: Language + ui: UISettings + notifications: NotificationSettings + autoConnect: boolean + websocketUrl: string + developerMode: boolean +} + +const DEFAULT_SETTINGS: AppSettings = { + theme: 'system', + language: 'en', + ui: { + navigationMode: 'hover', + compactMode: false, + showAnimations: true, + autoRefresh: true, + refreshInterval: 5, + dateFormat: 'relative', + timeFormat: '12h' + }, + notifications: { + enabled: true, + sound: true, + desktop: false, + email: false, + criticalOnly: false + }, + autoConnect: true, + websocketUrl: 'ws://192.168.1.73:8000/ws', + developerMode: false +} + +export const useSettingsStore = defineStore('settings', () => { + // State + const settings = reactive({ ...DEFAULT_SETTINGS }) + const isLoading = ref(false) + const lastSaved = ref(null) + + // Local storage key + const STORAGE_KEY = 'dashboard-settings' + + // Load settings from localStorage + function loadSettings() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + // Merge with defaults to handle new settings + Object.assign(settings, { ...DEFAULT_SETTINGS, ...parsed }) + console.log('Settings loaded from localStorage') + } + } catch (error) { + console.error('Failed to load settings:', error) + resetToDefaults() + } + } + + // Save settings to localStorage + function saveSettings() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + lastSaved.value = new Date() + console.log('Settings saved to localStorage') + } catch (error) { + console.error('Failed to save settings:', error) + } + } + + // Reset to default settings + function resetToDefaults() { + Object.assign(settings, DEFAULT_SETTINGS) + saveSettings() + } + + // Update specific setting + function updateSetting(path: string, value: any) { + const keys = path.split('.') + let current: any = settings + + for (let i = 0; i < keys.length - 1; i++) { + current = current[keys[i]] + } + + current[keys[keys.length - 1]] = value + saveSettings() + } + + // Get setting value by path + function getSetting(path: string): any { + const keys = path.split('.') + let current: any = settings + + for (const key of keys) { + current = current[key] + if (current === undefined) break + } + + return current + } + + // Export settings + function exportSettings(): string { + return JSON.stringify(settings, null, 2) + } + + // Import settings + function importSettings(settingsJson: string): boolean { + try { + const imported = JSON.parse(settingsJson) + // Validate structure + if (typeof imported === 'object' && imported !== null) { + Object.assign(settings, { ...DEFAULT_SETTINGS, ...imported }) + saveSettings() + return true + } + return false + } catch (error) { + console.error('Failed to import settings:', error) + return false + } + } + + // Theme helpers + function applyTheme() { + const root = document.documentElement + + if (settings.theme === 'dark') { + root.classList.add('dark') + } else if (settings.theme === 'light') { + root.classList.remove('dark') + } else { + // System theme + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + root.classList.toggle('dark', prefersDark) + } + } + + // WebSocket URL validation + function isValidWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'ws:' || parsed.protocol === 'wss:' + } catch { + return false + } + } + + // Notification permission handling + async function requestNotificationPermission(): Promise { + if (!('Notification' in window)) { + return false + } + + if (Notification.permission === 'granted') { + return true + } + + if (Notification.permission === 'denied') { + return false + } + + const permission = await Notification.requestPermission() + return permission === 'granted' + } + + // Initialize store + function initialize() { + loadSettings() + applyTheme() + + // Watch for theme changes + watch(() => settings.theme, applyTheme, { immediate: true }) + + // Watch for system theme changes + if (settings.theme === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + mediaQuery.addEventListener('change', applyTheme) + } + + // Auto-save on changes (debounced) + let saveTimeout: number | undefined + watch(settings, () => { + if (saveTimeout) clearTimeout(saveTimeout) + saveTimeout = window.setTimeout(saveSettings, 500) + }, { deep: true }) + } + + // Get available languages + function getAvailableLanguages() { + return [ + { code: 'en', name: 'English', nativeName: 'English' }, + { code: 'es', name: 'Spanish', nativeName: 'Espaรฑol' }, + { code: 'fr', name: 'French', nativeName: 'Franรงais' }, + { code: 'de', name: 'German', nativeName: 'Deutsch' } + ] + } + + // Get theme options + function getThemeOptions() { + return [ + { value: 'system', label: 'System Default', icon: '๐Ÿ”„' }, + { value: 'light', label: 'Light Mode', icon: 'โ˜€๏ธ' }, + { value: 'dark', label: 'Dark Mode', icon: '๐ŸŒ™' } + ] + } + + // Get navigation mode options + function getNavigationModeOptions() { + return [ + { + value: 'hover', + label: 'Show on Hover', + description: 'Navigation appears when hovering near bottom (desktop only)', + icon: '๐Ÿ‘†' + }, + { + value: 'always', + label: 'Always Visible', + description: 'Navigation is permanently visible', + icon: '๐Ÿ‘๏ธ' + }, + { + value: 'hidden', + label: 'Hidden', + description: 'Navigation is completely hidden', + icon: '๐Ÿซฅ' + } + ] + } + + return { + // State + settings, + isLoading, + lastSaved, + + // Actions + loadSettings, + saveSettings, + resetToDefaults, + updateSetting, + getSetting, + exportSettings, + importSettings, + applyTheme, + isValidWebSocketUrl, + requestNotificationPermission, + initialize, + + // Getters + getAvailableLanguages, + getThemeOptions, + getNavigationModeOptions + } +}) \ No newline at end of file diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 7ff47f7..af46ebd 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -66,9 +66,11 @@ import SensorConsumptionTable from '@/components/cards/SensorConsumptionTable.vu import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue' import AirQualityCard from '@/components/cards/AirQualityCard.vue' import { useEnergyStore } from '@/stores/energy' +import { useSettingsStore } from '@/stores/settings' import { computed, onMounted, onUnmounted } from 'vue' const energyStore = useEnergyStore() +const settingsStore = useSettingsStore() const currentEnergyValue = computed(() => { return energyStore.latestMessage?.value?.toFixed(2) || '0.00' @@ -82,7 +84,13 @@ const averageEnergyUsage = computed(() => { }) onMounted(() => { - energyStore.connect('ws://192.168.1.73:8000/ws') + // Initialize settings + settingsStore.initialize() + + // Auto-connect based on settings + if (settingsStore.settings.autoConnect) { + energyStore.connect(settingsStore.settings.websocketUrl) + } }) onUnmounted(() => { diff --git a/src/views/SensorManagementView.vue b/src/views/SensorManagementView.vue index f7ae655..fec162c 100644 --- a/src/views/SensorManagementView.vue +++ b/src/views/SensorManagementView.vue @@ -23,12 +23,24 @@
- +
+ + +