From b406fa94430ce188f9817bcbd380e364aff70901 Mon Sep 17 00:00:00 2001 From: Dominik9906 Date: Tue, 25 Aug 2026 21:26:05 +0200 Subject: [PATCH] FIX - UI and Bug Fix (#44) --- frontend/src/App.vue | 18 ++++----- .../AppDevelopmentPreview.contract.test.ts | 25 ++++++++++++ frontend/src/assets/main.css | 12 +++++- .../EasyShareSheet.contract.test.ts | 9 +++++ frontend/src/components/EasyShareSheet.vue | 9 +++-- frontend/src/stores/app-store.test.ts | 17 +++++++++ frontend/src/stores/phone.ts | 1 + .../views/apps/AppStoreApp.contract.test.ts | 4 ++ frontend/src/views/apps/AppStoreApp.vue | 38 ++++++++++++++++--- sky_phone/config/locales/de.lua | 3 +- sky_phone/config/locales/en.lua | 1 + sky_phone/config/locales/es.lua | 1 + sky_phone/source/client/focus.lua | 2 +- tests/client_focus.lua | 2 +- 14 files changed, 119 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b5a81b7..73f4c88 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -86,6 +86,7 @@ import { parsePhonePreferences } from '@/utils/preferences' import { getHairlinePixelStyle } from '@/utils/rendering' import { isTextInputElement } from '@/utils/textInputFocus' import { configurePhoneNumberFormat } from '@/utils/phone' +import { consumeEscape } from '@/utils/keyboard' import { isTrustedRootMessageSource } from '@/utils/windowMessages' import SpringboardView from '@/views/SpringboardView.vue' @@ -1315,20 +1316,17 @@ async function closePhone(): Promise { function onKeydown(event: KeyboardEvent): void { if (event.key !== 'Escape') return if (simPicker.value) { - event.preventDefault() + if (!consumeEscape(event)) return void closeSimPicker() return } - queueMicrotask(() => { - if (event.defaultPrevented || !phone.isOpen || activitySuspended.value) - return - if (controlCenterOpened.value) { - controlCenterOpened.value = false - return - } - void closePhone() - }) + if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return + if (controlCenterOpened.value) { + controlCenterOpened.value = false + return + } + void closePhone() } function onSystemColorSchemeChange(event: MediaQueryListEvent): void { diff --git a/frontend/src/AppDevelopmentPreview.contract.test.ts b/frontend/src/AppDevelopmentPreview.contract.test.ts index 4d054f2..26e599a 100644 --- a/frontend/src/AppDevelopmentPreview.contract.test.ts +++ b/frontend/src/AppDevelopmentPreview.contract.test.ts @@ -70,6 +70,31 @@ describe('browser development preview contract', () => { expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"') }) + it('clips composited app and overlay layers to the curved display', () => { + expect(mainCss).toMatch( + /\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s, + ) + }) + + it('replaces the CEF button focus rectangle around the home indicator', () => { + expect(mainCss).toMatch( + /\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s, + ) + expect(mainCss).toMatch( + /\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s, + ) + }) + + it('consumes Escape synchronously before FiveM can open the pause menu', () => { + expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'") + expect(source).toContain( + 'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return', + ) + expect(source).not.toMatch( + /function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/, + ) + }) + it('maps the visible device side controls to phone actions', () => { expect(source).toContain('@click="toggleHardwareAlertMute"') expect(source).toContain('@click="changeHardwareAlertVolume(10)"') diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 48ff04e..69214a3 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -460,6 +460,7 @@ button { } } .phone-screen { + --phone-screen-radius: 40px; --phone-screen-portrait-ratio: 2.30951; position: relative; container-type: size; @@ -469,7 +470,8 @@ button { height: 98%; overflow: hidden; background: #08080a; - border-radius: 40px; + border-radius: var(--phone-screen-radius); + clip-path: inset(0 round var(--phone-screen-radius)); } .phone-screen--camera-landscape { background: transparent; @@ -856,6 +858,14 @@ button { border-radius: 10px; box-shadow: 0 1px 4px #0008; } +.phone-home-indicator:focus { + outline: none; +} +.phone-home-indicator:focus-visible span { + box-shadow: + 0 0 0 2px #0a84ff, + 0 1px 4px #0008; +} .phone-home-indicator--interactive { cursor: pointer; } diff --git a/frontend/src/components/EasyShareSheet.contract.test.ts b/frontend/src/components/EasyShareSheet.contract.test.ts index cb93fa0..0307bdf 100644 --- a/frontend/src/components/EasyShareSheet.contract.test.ts +++ b/frontend/src/components/EasyShareSheet.contract.test.ts @@ -24,4 +24,13 @@ describe('EasyShareSheet Sky UI contract', () => { /\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s, ) }) + + it('only exposes and opens installed share destinations', () => { + expect(source).toContain("if (appStore.isInstalled('flare'))") + expect(source).toContain("if (appStore.isInstalled('darkchat'))") + expect(source).toContain('.filter((id) => appStore.isInstalled(id))') + expect(source).toContain('if (!appStore.isInstalled(kind)) return') + expect(source).toContain('if (!appStore.isInstalled(appId)) return') + expect(source).not.toContain('appStore.homeLayout.hidden.includes') + }) }) diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue index 9aafd6f..3b4d8b8 100644 --- a/frontend/src/components/EasyShareSheet.vue +++ b/frontend/src/components/EasyShareSheet.vue @@ -67,7 +67,7 @@ const sharePeople = computed(() => { }> = [] const phoneNumbers = new Set() - if (!appStore.homeLayout.hidden.includes('flare')) { + if (appStore.isInstalled('flare')) { for (const match of flare.matches) { people.push({ avatar: match.profile.photoUrls[0], @@ -102,7 +102,7 @@ const sharePeople = computed(() => { phoneNumbers.add(contact.phone_number) } - if (!appStore.homeLayout.hidden.includes('darkchat')) { + if (appStore.isInstalled('darkchat')) { for (const conversation of darkChat.conversations.slice(0, 8)) { people.push({ kind: 'darkchat', @@ -116,7 +116,7 @@ const sharePeople = computed(() => { }) const shareApps = computed(() => (easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : []) - .filter((id) => !appStore.homeLayout.hidden.includes(id)) + .filter((id) => appStore.isInstalled(id)) .flatMap((id) => { const app = getPhoneApp(id) return app ? [{ app, id }] : [] @@ -195,18 +195,21 @@ function endDrag(event: PointerEvent): void { } function shareToChat(kind: EasyShareChatApp, targetId: string): void { + if (!appStore.isInstalled(kind)) return if (!easyShare.prepareChatDraft(kind, targetId)) return close() void router.push(`/apps/${kind}`) } function openChatApp(kind: EasyShareChatApp): void { + if (!appStore.isInstalled(kind)) return if (!easyShare.prepareChatDraft(kind)) return close() void router.push(`/apps/${kind}`) } function openShareApp(appId: EasyShareDestinationApp): void { + if (!appStore.isInstalled(appId)) return if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') { openChatApp(appId) return diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index b82a21f..5e37f02 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -264,6 +264,23 @@ describe('app store', () => { expect(apps.homeLayout.hidden).not.toContain('snake') }) + it('uninstalls claimed Banking and Picstagram apps from every app state', () => { + const apps = useAppStoreStore() + apps.hydrate({ claimedApps: ['banking', 'picstagram'] }) + mocks.phone.saveDeviceNamespace.mockClear() + + for (const appId of ['banking', 'picstagram'] as const) { + expect(apps.isInstalled(appId)).toBe(true) + expect(apps.uninstallApp(appId)).toBe(true) + expect(apps.isInstalled(appId)).toBe(false) + expect(apps.claimedApps).not.toContain(appId) + expect(apps.uninstalledApps).toContain(appId) + expect(apps.homeLayout.hidden).toContain(appId) + } + + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2) + }) + it('hydrates persisted removals while rejecting protected and invalid ids', () => { const apps = useAppStoreStore() diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 67f53f0..7249657 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -3027,6 +3027,7 @@ const defaultLocales: LocaleTree = { uninstallTitle: 'Uninstall this app?', uninstallBody: '{app} will be removed from this phone. You can download it again from the App Store.', + uninstallFailed: 'The app could not be uninstalled. Please try again.', }, details: { skyStudios: 'Sky Studios', diff --git a/frontend/src/views/apps/AppStoreApp.contract.test.ts b/frontend/src/views/apps/AppStoreApp.contract.test.ts index f99a1bc..e45fead 100644 --- a/frontend/src/views/apps/AppStoreApp.contract.test.ts +++ b/frontend/src/views/apps/AppStoreApp.contract.test.ts @@ -101,6 +101,10 @@ describe('AppStoreApp Sky navigation contract', () => { expect(source).toContain( 'appStore.uninstallApp(uninstallCandidate.value.id)', ) + expect(source).toContain('@click.stop="requestUninstall(app)"') + expect(source).toContain('if (!appStore.uninstallApp(') + expect(source).toContain('Apps.appStore.account.uninstallFailed') + expect(source).toContain('role="alert"') expect(source).toContain('v-if="isPhoneAppRemovable(app)"') expect(source).toContain(':opened="Boolean(uninstallCandidate)"') expect(source).toContain('class="store-account__grabber"') diff --git a/frontend/src/views/apps/AppStoreApp.vue b/frontend/src/views/apps/AppStoreApp.vue index 9d7a087..5f27488 100644 --- a/frontend/src/views/apps/AppStoreApp.vue +++ b/frontend/src/views/apps/AppStoreApp.vue @@ -63,6 +63,7 @@ const openedAt = new Date() const featuredSlide = ref(0) const profileOpened = ref(false) const uninstallCandidate = ref(null) +const uninstallError = ref('') const selectedApp = ref(null) const storeScroll = ref(null) const featuredScroller = ref(null) @@ -288,6 +289,16 @@ function closeProfile(): void { profileDragOffset.value = 0 } +function requestUninstall(app: LaunchablePhoneAppDefinition): void { + uninstallError.value = '' + uninstallCandidate.value = app +} + +function closeUninstallDialog(): void { + uninstallError.value = '' + uninstallCandidate.value = null +} + function beginProfileDrag(event: PointerEvent): void { if (!profileOpened.value || event.button !== 0) return profileDragPointerId = event.pointerId @@ -318,8 +329,11 @@ function endProfileDrag(event: PointerEvent): void { function confirmUninstall(): void { if (!uninstallCandidate.value) return - appStore.uninstallApp(uninstallCandidate.value.id) - uninstallCandidate.value = null + if (!appStore.uninstallApp(uninstallCandidate.value.id)) { + uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed') + return + } + closeUninstallDialog() } function highlightStyle(index: number): Record { @@ -1107,7 +1121,7 @@ watch( app: getPhoneAppLabel(app, phone.t), }) " - @click="uninstallCandidate = app" + @click.stop="requestUninstall(app)" >