From ca778d1eb74192a950b945631a48cbdcda7411db Mon Sep 17 00:00:00 2001 From: Eichenholz Date: Sat, 8 Aug 2026 09:08:18 +0200 Subject: [PATCH] ADD - add iOS-style control center --- frontend/src/App.vue | 53 +- frontend/src/assets/main.css | 35 +- .../src/components/PhoneControlCenter.vue | 927 ++++++++++++++++++ frontend/src/components/PhoneStatusBar.vue | 39 +- frontend/src/stores/notifications.test.ts | 23 + frontend/src/stores/notifications.ts | 1 + frontend/src/stores/phone.ts | 31 + frontend/src/utils/preferences.test.ts | 30 + frontend/src/utils/preferences.ts | 32 + frontend/src/views/apps/ClockApp.vue | 13 +- sky_phone/config/locales/en.lua | 7 + sky_phone/source/html/index.html | 4 +- 12 files changed, 1182 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/PhoneControlCenter.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7599303..91e22f1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,6 +11,7 @@ import { import { useRoute, useRouter } from 'vue-router' import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue' +import PhoneControlCenter from '@/components/PhoneControlCenter.vue' import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue' import PhoneLockScreen from '@/components/PhoneLockScreen.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' @@ -139,6 +140,7 @@ const appTransitionName = computed(() => ) const isLocked = ref(false) const isUnlocking = ref(false) +const controlCenterOpened = ref(false) const simPicker = ref(null) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') const viewportScale = ref(getViewportScale()) @@ -149,6 +151,11 @@ const phoneResolutionStyle = computed(() => ({ '--phone-zoom': phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100), })) +const phoneDisplayStyle = computed(() => ({ + '--phone-display-dim': String( + ((100 - phone.preferences.settings.screenBrightness) / 100) * 0.8, + ), +})) const phoneFrameImage = computed( () => PHONE_FRAME_IMAGES[phone.preferences.settings.frame], ) @@ -338,17 +345,26 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'darkchat:new' && event.data.data) { const data = event.data.data as DarkChatEventData void darkchat.refreshInbox() - if (data.conversationId && darkchat.activeConversation?.id === data.conversationId) { + if ( + data.conversationId && + darkchat.activeConversation?.id === data.conversationId + ) { void darkchat.openThread(data.conversationId) } if (data.notificationMode !== 'hidden') { const notification: PhoneNotificationInput = { appId: 'darkchat', subtitle: data.sender, - text: data.text ?? data.preview ?? phone.t('Apps.darkchat.privateNotification'), + text: + data.text ?? + data.preview ?? + phone.t('Apps.darkchat.privateNotification'), title: data.title ?? phone.t('Apps.darkchat.name'), } - if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) { + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { notification.device = { imei: data.device.imei, name: data.device.name, @@ -367,6 +383,7 @@ function onMessage(event: MessageEvent): void { event.data.data ) { calls.applyCallState(event.data.data as PhoneCall) + controlCenterOpened.value = false isLocked.value = false isUnlocking.value = false window.setTimeout(() => void router.push('/apps/phone'), 0) @@ -379,6 +396,10 @@ function onMessage(event: MessageEvent): void { function onKeydown(event: KeyboardEvent): void { if (event.key !== 'Escape' || !phone.isOpen) return + if (controlCenterOpened.value) { + controlCenterOpened.value = false + return + } phone.close() void nuiCall('close') } @@ -401,6 +422,11 @@ function unlockPhone(): void { }, 720) } +function toggleControlCenter(): void { + if (isLocked.value) return + controlCenterOpened.value = !controlCenterOpened.value +} + function unlockCamera(): void { unlockPhone() window.setTimeout(() => void router.push('/apps/camera'), 0) @@ -487,11 +513,13 @@ watch( if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { weather.stop() + controlCenterOpened.value = false isLocked.value = false isUnlocking.value = false return } isLocked.value = true + controlCenterOpened.value = false weather.start() isUnlocking.value = false phone.setLaunchOrigin(null) @@ -499,6 +527,13 @@ watch( }, ) +watch( + () => phone.cameraLandscape, + (landscape) => { + if (landscape) controlCenterOpened.value = false + }, +) + onBeforeUnmount(() => { weather.stop() if (clockTicker) clearInterval(clockTicker) @@ -560,13 +595,18 @@ onBeforeUnmount(() => { :dark="phone.isDarkMode" safe-areas class="phone-app" + :style="phoneDisplayStyle" :class="{ dark: phone.isDarkMode, 'phone-app--light': !phone.isDarkMode, 'phone-app--unlocking': isUnlocking, }" > - + @@ -578,6 +618,10 @@ onBeforeUnmount(() => { + { :notification="notifications.current" @close="notifications.dismissCurrent()" /> + +import { kGlass, kRange } from 'konsta/vue' +import { + BellOff, + Bluetooth, + Calculator, + Camera, + Flashlight, + LockKeyhole, + Moon, + Plane, + Play, + RotateCw, + Signal, + SkipBack, + SkipForward, + Sun, + TimerReset, + Volume2, + Wifi, +} from 'lucide-vue-next' +import { + computed, + nextTick, + onBeforeUnmount, + ref, + watch, + type CSSProperties, +} from 'vue' +import { useRouter } from 'vue-router' + +import { usePhoneStore } from '@/stores/phone' +import { nuiCall } from '@/utils/nui' + +const props = defineProps<{ opened: boolean }>() +const emit = defineEmits<{ close: [] }>() + +type ConnectivityPreference = + | 'airplaneMode' + | 'bluetoothEnabled' + | 'cellularEnabled' + | 'focusMode' + | 'rotationLocked' + | 'wifiEnabled' + +const phone = usePhoneStore() +const router = useRouter() +const panel = ref(null) +const brightness = ref(phone.preferences.settings.screenBrightness) +const volume = ref( + Math.round( + (phone.preferences.settings.notificationVolume + + phone.preferences.settings.ringtoneVolume) / + 2, + ), +) +const flashlightActive = ref(false) +const flashlightPending = ref(false) +const previousAlertVolume = ref(volume.value || 75) + +const inactiveGlassColors = { + bgIos: 'bg-[rgba(72,72,74,0.58)]', + shadowIos: 'shadow-ios-dark-glass', +} +const rotationColors = computed(() => + phone.preferences.settings.rotationLocked + ? { + bgIos: 'bg-white', + shadowIos: 'shadow-ios-light-glass', + } + : inactiveGlassColors, +) +const focusColors = computed(() => + phone.preferences.settings.focusMode + ? { + bgIos: 'bg-[#5e5ce6]', + shadowIos: 'shadow-ios-dark-glass', + } + : inactiveGlassColors, +) +const alertsMuted = computed(() => volume.value === 0) +const alertMuteColors = computed(() => + alertsMuted.value + ? { + bgIos: 'bg-white', + shadowIos: 'shadow-ios-light-glass', + } + : inactiveGlassColors, +) +const flashlightColors = computed(() => + flashlightActive.value + ? { + bgIos: 'bg-white', + shadowIos: 'shadow-ios-light-glass', + } + : inactiveGlassColors, +) +const brightnessStyle = computed(() => ({ + '--control-level': `${brightness.value}%`, +})) +const volumeStyle = computed(() => ({ + '--control-level': `${volume.value}%`, +})) + +function togglePreference(key: ConnectivityPreference): void { + phone.setPreference(key, !phone.preferences.settings[key]) +} + +function updateBrightness(event: Event): void { + const nextValue = Number.parseInt( + (event.target as HTMLInputElement).value, + 10, + ) + brightness.value = nextValue + phone.preferences.settings.screenBrightness = nextValue +} + +function saveBrightness(event: Event): void { + phone.setPreference( + 'screenBrightness', + Number.parseInt((event.target as HTMLInputElement).value, 10), + ) +} + +function updateVolume(event: Event): void { + volume.value = Number.parseInt((event.target as HTMLInputElement).value, 10) +} + +function saveVolume(event: Event): void { + const nextValue = Number.parseInt( + (event.target as HTMLInputElement).value, + 10, + ) + if (nextValue > 0) previousAlertVolume.value = nextValue + phone.setAlertVolumes(nextValue) +} + +function toggleAlertMute(): void { + if (alertsMuted.value) { + volume.value = previousAlertVolume.value + } else { + previousAlertVolume.value = volume.value + volume.value = 0 + } + phone.setAlertVolumes(volume.value) +} + +function pointerRangeValue( + event: PointerEvent, + minimum: number, + maximum: number, +): number { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() + const ratio = + 1 - Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)) + return Math.round(minimum + ratio * (maximum - minimum)) +} + +function startBrightnessDrag(event: PointerEvent): void { + ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) + const nextValue = pointerRangeValue(event, 10, 100) + brightness.value = nextValue + phone.preferences.settings.screenBrightness = nextValue +} + +function dragBrightness(event: PointerEvent): void { + if (!(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId)) + return + const nextValue = pointerRangeValue(event, 10, 100) + brightness.value = nextValue + phone.preferences.settings.screenBrightness = nextValue +} + +function finishBrightnessDrag(event: PointerEvent): void { + const target = event.currentTarget as HTMLElement + if (!target.hasPointerCapture(event.pointerId)) return + const nextValue = pointerRangeValue(event, 10, 100) + brightness.value = nextValue + phone.setPreference('screenBrightness', nextValue) + target.releasePointerCapture(event.pointerId) +} + +function startVolumeDrag(event: PointerEvent): void { + ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) + volume.value = pointerRangeValue(event, 0, 100) +} + +function dragVolume(event: PointerEvent): void { + if (!(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId)) + return + volume.value = pointerRangeValue(event, 0, 100) +} + +function finishVolumeDrag(event: PointerEvent): void { + const target = event.currentTarget as HTMLElement + if (!target.hasPointerCapture(event.pointerId)) return + volume.value = pointerRangeValue(event, 0, 100) + if (volume.value > 0) previousAlertVolume.value = volume.value + phone.setAlertVolumes(volume.value) + target.releasePointerCapture(event.pointerId) +} + +async function toggleFlashlight(): Promise { + if (flashlightPending.value) return + const enabled = !flashlightActive.value + flashlightActive.value = enabled + flashlightPending.value = true + const response = await nuiCall('camera:setFlash', { enabled }) + if (!response.success) flashlightActive.value = !enabled + flashlightPending.value = false +} + +function openApp(path: string): void { + emit('close') + void router.push(path) +} + +function openTimer(): void { + emit('close') + void router.push({ path: '/apps/clock', query: { section: 'timer' } }) +} + +watch( + () => props.opened, + (opened) => { + if (!opened) return + brightness.value = phone.preferences.settings.screenBrightness + volume.value = Math.round( + (phone.preferences.settings.notificationVolume + + phone.preferences.settings.ringtoneVolume) / + 2, + ) + if (volume.value > 0) previousAlertVolume.value = volume.value + void nextTick(() => panel.value?.focus({ preventScroll: true })) + }, +) + +onBeforeUnmount(() => { + if (flashlightActive.value) + void nuiCall('camera:setFlash', { enabled: false }) +}) + + + + + diff --git a/frontend/src/components/PhoneStatusBar.vue b/frontend/src/components/PhoneStatusBar.vue index c336e26..74ae40d 100644 --- a/frontend/src/components/PhoneStatusBar.vue +++ b/frontend/src/components/PhoneStatusBar.vue @@ -1,9 +1,13 @@ - + +