From e9cd22097195ed7fb9e4ed16b1dd1d6a9d032fb4 Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:18:00 +0200 Subject: [PATCH 1/3] ENH - polish core phone app interfaces Adds an active-call Dynamic Island and refines the Phone, Health, Notes, and Companies experiences with shared Sky UI behavior, improved keyboard interaction, and localized note deletion confirmation. Updates the relevant contract and component tests. --- frontend/src/App.vue | 13 +- frontend/src/assets/main.css | 70 ++++++ .../NotesRichTextEditor.contract.test.ts | 2 + .../src/components/NotesRichTextEditor.vue | 7 +- .../PhoneDynamicIsland.contract.test.ts | 24 ++ .../src/components/PhoneDynamicIsland.vue | 81 ++++++ frontend/src/stores/phone.ts | 2 + frontend/src/ui/controls.css | 23 ++ frontend/src/ui/controls/SkyListItem.test.ts | 20 +- frontend/src/ui/controls/SkyListItem.vue | 5 + frontend/src/views/apps/CompaniesApp.vue | 7 +- .../src/views/apps/HealthApp.contract.test.ts | 7 + frontend/src/views/apps/HealthApp.vue | 111 ++++++++- .../views/apps/NotesApp.menu.contract.test.ts | 11 +- frontend/src/views/apps/NotesApp.vue | 78 +++++- frontend/src/views/apps/PhoneApp.vue | 232 ++++-------------- sky_phone/config/locales/en.lua | 3 +- 17 files changed, 486 insertions(+), 210 deletions(-) create mode 100644 frontend/src/components/PhoneDynamicIsland.contract.test.ts create mode 100644 frontend/src/components/PhoneDynamicIsland.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9bf21f7..5afd779 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -20,6 +20,7 @@ import PhoneSetupAssistant from '@/components/PhoneSetupAssistant.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' +import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue' import EasyShareSheet from '@/components/EasyShareSheet.vue' import PayphoneOverlay from '@/components/PayphoneOverlay.vue' import RadioHud from '@/components/RadioHud.vue' @@ -1031,7 +1032,10 @@ function onMessage(event: MessageEvent): void { isUnlocking.value = false loadUnlockedPhoneData() } - window.setTimeout(() => void router.push('/apps/phone'), 0) + const call = event.data.data as PhoneCall + if (call.state !== 'ringing' || call.direction !== 'incoming') { + window.setTimeout(() => void router.push('/apps/phone'), 0) + } } else if (event.data?.type === 'sim:picker' && event.data.data) { simPicker.value = event.data.data as unknown as SimPickerPayload } else if (event.data?.type === 'sim:picker-close') { @@ -1243,6 +1247,10 @@ function returnToActiveCall(): void { void router.push('/apps/phone') } +function openAcceptedCall(): void { + void router.push('/apps/phone') +} + function unlockCamera(): void { if (phone.security.enabled) { pendingUnlockRoute.value = '/apps/camera' @@ -1449,7 +1457,7 @@ onBeforeUnmount(() => { @open="openNotificationPreview" />
{ @control-center="toggleControlCenter" @lock="lockPhone" /> + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 84d8af8..3abbf02 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -647,6 +647,76 @@ button { cursor: pointer; pointer-events: auto; } +.phone-dynamic-island { + position: absolute; + z-index: 98; + top: 12px; + left: 50%; + display: flex; + align-items: center; + gap: 10px; + width: 300px; + min-height: 72px; + padding: 10px 12px 10px 16px; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 28px; + color: #fff; + background: #050505; + box-shadow: 0 8px 24px rgb(0 0 0 / 45%); + transform: translateX(-50%); +} +.phone-dynamic-island__caller { + min-width: 0; + flex: 1; +} +.phone-dynamic-island__caller span, +.phone-dynamic-island__caller strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.phone-dynamic-island__caller span { + color: rgb(255 255 255 / 58%); + font-size: 11px; + line-height: 14px; +} +.phone-dynamic-island__caller strong { + margin-top: 2px; + font-size: 15px; + line-height: 18px; +} +.phone-dynamic-island__actions { + display: flex; + gap: 8px; +} +.phone-dynamic-island__actions .sky-button--icon-only { + width: 44px; + min-width: 44px; + height: 44px; +} +.phone-dynamic-island__actions svg { + width: 19px; + height: 19px; +} +.phone-dynamic-island__answer { + --sky-app-accent: #34c759; +} +.phone-dynamic-island-enter-active, +.phone-dynamic-island-leave-active { + transition: opacity 180ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.phone-dynamic-island-enter-from, +.phone-dynamic-island-leave-to { + opacity: 0; + transform: translateX(-50%) scale(0.86); +} +@media (prefers-reduced-motion: reduce) { + .phone-dynamic-island-enter-active, + .phone-dynamic-island-leave-active { + transition: none; + } +} .phone-home-indicator { position: absolute; z-index: 90; diff --git a/frontend/src/components/NotesRichTextEditor.contract.test.ts b/frontend/src/components/NotesRichTextEditor.contract.test.ts index d74dea9..ff3a7aa 100644 --- a/frontend/src/components/NotesRichTextEditor.contract.test.ts +++ b/frontend/src/components/NotesRichTextEditor.contract.test.ts @@ -21,6 +21,8 @@ describe('NotesRichTextEditor formatting tabbar', () => { expect(source).toContain('@click="formatMode = true"') expect(source).toContain('@click="formatMode = false"') expect(source).toContain(':disabled="editor.state.selection.empty"') + expect(source).toContain('class="notes-rich-editor sky-ui-provider"') + expect(source).toContain("'sky-ui-provider--dark': dark") expect(source).not.toContain('notes-rich-editor__toolbar-row') expect(source).not.toContain('scrollToolbar') }) diff --git a/frontend/src/components/NotesRichTextEditor.vue b/frontend/src/components/NotesRichTextEditor.vue index 5894af1..f5abd65 100644 --- a/frontend/src/components/NotesRichTextEditor.vue +++ b/frontend/src/components/NotesRichTextEditor.vue @@ -263,8 +263,11 @@ onBeforeUnmount(() => editor.value?.destroy()) diff --git a/frontend/src/views/apps/CompaniesApp.vue b/frontend/src/views/apps/CompaniesApp.vue index 8f1835d..b4b4aa8 100644 --- a/frontend/src/views/apps/CompaniesApp.vue +++ b/frontend/src/views/apps/CompaniesApp.vue @@ -352,12 +352,7 @@ function relativeTime(value: string): string { } function companyInitials(company: CompanySummary): string { - return company.name - .split(/\s+/) - .slice(0, 2) - .map((word) => word[0] ?? '') - .join('') - .toUpperCase() + return company.name.trim().charAt(0).toUpperCase() } function companySubtitle(company: CompanySummary): string { diff --git a/frontend/src/views/apps/HealthApp.contract.test.ts b/frontend/src/views/apps/HealthApp.contract.test.ts index 0852dbd..5735c5d 100644 --- a/frontend/src/views/apps/HealthApp.contract.test.ts +++ b/frontend/src/views/apps/HealthApp.contract.test.ts @@ -28,6 +28,13 @@ describe('HealthApp Sky UI contract', () => { expect(source).toContain('health.saveMedicalId') }) + it('uses the Sky iOS action sheet instead of a native blood type select', () => { + expect(source).toContain(' { expect(source).not.toContain('healthPercent') expect(source).not.toContain('recoveryPercent') diff --git a/frontend/src/views/apps/HealthApp.vue b/frontend/src/views/apps/HealthApp.vue index 2e15f1b..37ef1f5 100644 --- a/frontend/src/views/apps/HealthApp.vue +++ b/frontend/src/views/apps/HealthApp.vue @@ -2,6 +2,8 @@ import { Activity, ChartNoAxesColumnIncreasing, + Check, + ChevronDown, ContactRound, Flame, Footprints, @@ -19,6 +21,10 @@ import { usePhoneStore } from '@/stores/phone' import type { HealthMedicalIdInput } from '@/types/health' import { SkyAppPage, + SkyActionButton, + SkyActionGroup, + SkyActionSheet, + SkyActionsLabel, SkyButton, SkyEmptyState, SkyField, @@ -39,6 +45,7 @@ const phone = usePhoneStore() const health = useHealthStore() const activeTab = ref('today') const editingMedicalId = ref(false) +const bloodTypePickerOpened = ref(false) const actionError = ref('') const medicalDraft = reactive({ allergies: '', @@ -168,6 +175,7 @@ function syncMedicalDraft(): void { async function toggleMedicalEdit(): Promise { actionError.value = '' + bloodTypePickerOpened.value = false if (!editingMedicalId.value) { syncMedicalDraft() editingMedicalId.value = true @@ -185,6 +193,11 @@ async function toggleMedicalEdit(): Promise { : translatedError } +function selectBloodType(value: string): void { + medicalDraft.bloodType = value + bloodTypePickerOpened.value = false +} + async function dial(number: string): Promise { if (!number) return actionError.value = '' @@ -406,12 +419,18 @@ onBeforeUnmount(() => {

{{ phone.t('Apps.health.medicalId.emergencyInformation') }}

    - +
  • + +
  • { + + + + {{ phone.t('Apps.health.medicalId.bloodType') }} + + + {{ option.label }} + + + + + {{ phone.t('Common.cancel') }} + + + + { list-style: none; } +.health-blood-type-field button { + display: grid; + width: 100%; + min-height: 58px; + align-items: center; + padding: 8px 16px; + border: 0; + background: transparent; + color: var(--sky-text); + cursor: pointer; + grid-template-columns: 1fr auto auto; + grid-template-rows: auto auto; + text-align: left; +} + +.health-blood-type-field button:focus-visible { + outline: 2px solid var(--sky-app-accent); + outline-offset: -2px; +} + +.health-blood-type-field span { + grid-column: 1 / -1; + color: var(--sky-muted); + font-size: 12px; + line-height: 16px; +} + +.health-blood-type-field strong { + font-size: 17px; + font-weight: 400; + line-height: 24px; +} + +.health-blood-type-field svg { + color: var(--sky-muted); +} + +.health-blood-type-option { + display: flex; + align-items: center; + justify-content: space-between; +} + +.health-blood-type-option svg { + color: var(--sky-app-accent); +} + .health-action-error { margin: 0; padding: 0 4px; diff --git a/frontend/src/views/apps/NotesApp.menu.contract.test.ts b/frontend/src/views/apps/NotesApp.menu.contract.test.ts index ed2df99..f23451a 100644 --- a/frontend/src/views/apps/NotesApp.menu.contract.test.ts +++ b/frontend/src/views/apps/NotesApp.menu.contract.test.ts @@ -8,7 +8,7 @@ const menuSource = source.slice( source.indexOf('') + ''.length, ) const listSource = source.slice( - source.indexOf(' { expect(composerSource).toContain('component="footer"') expect(listSource).toContain('(null) const editorOpened = ref(false) const draftBody = ref('') const menuOpened = ref(false) +const listDeleteCandidateId = ref(null) const currentNote = computed(() => editorId.value ? notes.notes.find((note) => note.id === editorId.value) @@ -121,6 +125,21 @@ function editNote(note: Note): void { editorOpened.value = true } +function requestListDelete(note: Note): void { + listDeleteCandidateId.value = note.id +} + +function cancelListDelete(): void { + listDeleteCandidateId.value = null +} + +function confirmListDelete(): void { + const noteId = listDeleteCandidateId.value + if (!noteId) return + notes.deleteNote(noteId) + listDeleteCandidateId.value = null +} + function persistDraft(): Note | undefined { const draft = { body: draftBody.value, @@ -185,28 +204,41 @@ function shareNote(): void {