ADD - render interactive iOS widgets

This commit is contained in:
Leon.Schmidt
2026-08-08 22:48:03 +02:00
parent c60fa8d3b7
commit 95875397f5
@@ -0,0 +1,831 @@
<script setup lang="ts">
import {
Banknote,
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSun,
MessageCircle,
MoonStar,
Pause,
Phone,
Play,
SkipForward,
Snowflake,
Sun,
WalletCards,
} from 'lucide-vue-next'
import { kBadge, kGlass } from 'konsta/vue'
import { computed, onBeforeUnmount, ref, type Component } from 'vue'
import { useRouter } from 'vue-router'
import {
useBankService,
useClockService,
useContactsService,
useMusicService,
useWeatherService,
} from '@/services/widgetServices'
import { useCallsStore } from '@/stores/calls'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { WidgetInstance } from '@/types/widgets'
import type { WeatherConditionId } from '@/types/weather'
import { WIDGET_SPANS } from '@/utils/widgetLayout'
const props = withDefaults(
defineProps<{
editMode?: boolean
instance: WidgetInstance
interactive?: boolean
preview?: boolean
}>(),
{ editMode: false, interactive: true, preview: false },
)
const emit = defineEmits<{
dragcancel: []
dragend: [event: PointerEvent]
dragmove: [event: PointerEvent]
dragstart: [event: PointerEvent]
menu: []
remove: []
}>()
const phone = usePhoneStore()
const calls = useCallsStore()
const messages = useMessagesStore()
const router = useRouter()
const clock = useClockService()
const weather = useWeatherService()
const music = useMusicService()
const bank = useBankService()
const contactsService = useContactsService()
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const suppressClick = ref(false)
let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let dragStartPage = 0
let dragPageWidth = 0
const weatherIcons: Record<WeatherConditionId, Component> = {
sunny: Sun,
clear: MoonStar,
partly_cloudy: CloudSun,
cloudy: Cloud,
rain: CloudRain,
thunder: CloudLightning,
fog: CloudFog,
snow: Snowflake,
}
const span = computed(() => WIDGET_SPANS[props.instance.size])
const placementStyle = computed(() =>
props.preview
? undefined
: {
gridColumn: `${props.instance.column + 1} / span ${span.value.columns}`,
gridRow: `${props.instance.row + 1} / span ${span.value.rows}`,
translate: isDragging.value
? `${dragOffset.value.x}px ${dragOffset.value.y}px`
: undefined,
transform: isDragging.value
? `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px) scale(1.035)`
: undefined,
},
)
const forecastHigh = computed(() =>
weather.forecast.value?.hourly.length
? Math.max(
...weather.forecast.value.hourly.map((entry) => entry.temperature),
)
: null,
)
const forecastLow = computed(() =>
weather.forecast.value?.hourly.length
? Math.min(
...weather.forecast.value.hourly.map((entry) => entry.temperature),
)
: null,
)
const weatherIcon = computed(
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
)
const balance = computed(() =>
props.instance.settings.balanceSource === 'cash'
? bank.overview.value.cash
: bank.overview.value.bank,
)
const favoriteContacts = computed(() => {
const selected = props.instance.settings.contactIds ?? []
const ordered = selected.length
? selected
.map((id) =>
contactsService.contacts.value.find((contact) => contact.id === id),
)
.filter((contact) => contact !== undefined)
: contactsService.contacts.value
return ordered.slice(0, props.instance.size === 'large' ? 6 : 4)
})
const visibleTransactions = computed(() =>
bank.overview.value.transactions.slice(
0,
props.instance.size === 'large' ? 5 : 2,
),
)
const removeBadgeColors = {
bg: 'bg-[#d1d1d6]',
text: 'text-black',
}
function formatMoney(value: number): string {
return `${bank.overview.value.currency}${new Intl.NumberFormat(phone.lang, {
maximumFractionDigits: 0,
}).format(value)}`
}
function avatar(name: string): string {
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
}
function clearHold(): void {
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
holdTimer = undefined
}
function onPointerDown(event: PointerEvent): void {
if (
!props.interactive ||
props.preview ||
event.button !== 0 ||
(event.target as HTMLElement).closest('[data-widget-control]')
) {
return
}
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
beginDrag(event)
return
}
holdTimer = window.setTimeout(() => {
suppressClick.value = true
emit('menu')
holdTimer = undefined
}, 520)
}
function onPointerMove(event: PointerEvent): void {
if (isDragging.value) {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
emit('dragmove', event)
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
) {
clearHold()
}
}
function beginDrag(event: PointerEvent): void {
dragStartPage = phone.currentPage
dragPageWidth =
(event.currentTarget as HTMLElement)
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
}
function cancelDrag(): void {
clearHold()
if (!isDragging.value) return
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelDrag)
}
function openWidget(): void {
if (props.editMode || suppressClick.value || !props.interactive) {
suppressClick.value = false
return
}
const routes: Partial<Record<WidgetInstance['kind'], string>> = {
clock: '/apps/clock',
contacts: '/apps/phone',
date: '/apps/calendar',
transactions: '/apps/banking',
wallet: '/apps/banking',
weather: '/apps/weather',
}
const route = routes[props.instance.kind]
if (route) {
phone.setLaunchOrigin(null)
void router.push(route)
}
}
async function callContact(phoneNumber: string): Promise<void> {
await calls.dial(phoneNumber)
phone.setLaunchOrigin(null)
void router.push('/apps/phone')
}
async function messageContact(phoneNumber: string): Promise<void> {
await messages.openThread(phoneNumber)
phone.setLaunchOrigin(null)
void router.push('/apps/messages')
}
onBeforeUnmount(() => {
clearHold()
removeDragListeners()
})
</script>
<template>
<div
class="home-widget-shell"
:class="[
`home-widget-shell--${instance.size}`,
{
'home-widget-shell--dragging': isDragging,
'home-widget-shell--editing': editMode,
'home-widget-shell--preview': preview,
},
]"
:style="placementStyle"
:data-widget-id="instance.id"
>
<k-glass
component="article"
class="home-widget"
:class="`home-widget--${instance.kind}`"
role="button"
tabindex="0"
@click="openWidget"
@contextmenu.prevent
@keydown.enter="openWidget"
@pointercancel="cancelDrag"
@pointerdown="onPointerDown"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<template v-if="instance.kind === 'clock'">
<span class="widget-eyebrow">{{ clock.weekday.value }}</span>
<strong class="widget-clock">{{ clock.time.value }}</strong>
<small v-if="instance.settings.showDate !== false">{{
clock.date.value
}}</small>
</template>
<template v-else-if="instance.kind === 'date'">
<span class="widget-date-month">{{ clock.month.value }}</span>
<strong class="widget-date-day">{{ clock.day.value }}</strong>
<small>{{ clock.weekday.value }}</small>
</template>
<template v-else-if="instance.kind === 'weather'">
<div class="widget-weather-top">
<div>
<span class="widget-eyebrow">{{ weather.location.value }}</span>
<strong>{{ weather.forecast.value?.temperature ?? '--' }}°</strong>
</div>
<component
:is="weatherIcon"
:size="instance.size === 'small' ? 30 : 40"
/>
</div>
<small>{{ weather.condition.value }}</small>
<small v-if="instance.size !== 'small'" class="widget-weather-range">
H: {{ forecastHigh ?? '--' }}° &nbsp; L: {{ forecastLow ?? '--' }}°
</small>
</template>
<template v-else-if="instance.kind === 'music'">
<div class="widget-album" aria-hidden="true">
<span>SKY</span>
</div>
<div class="widget-music-copy">
<strong>{{ music.current.value.title }}</strong>
<small>{{ music.current.value.artist }}</small>
</div>
<div class="widget-music-controls" data-widget-control>
<button
type="button"
:aria-label="
phone.t(
music.playing.value
? 'Home.widgets.media.pause'
: 'Home.widgets.media.play',
)
"
@click.stop="music.toggle"
>
<Pause v-if="music.playing.value" :size="20" fill="currentColor" />
<Play v-else :size="20" fill="currentColor" />
</button>
<button
type="button"
:aria-label="phone.t('ControlCenter.next')"
@click.stop="music.next"
>
<SkipForward :size="19" fill="currentColor" />
</button>
</div>
</template>
<template v-else-if="instance.kind === 'wallet'">
<div class="widget-wallet-icon"><WalletCards :size="22" /></div>
<span class="widget-eyebrow">{{
phone.t(
instance.settings.balanceSource === 'cash'
? 'Home.widgetSystem.wallet.cash'
: 'Home.widgetSystem.wallet.bank',
)
}}</span>
<strong class="widget-balance">{{ formatMoney(balance) }}</strong>
<small>{{ bank.overview.value.playerName }}</small>
</template>
<template v-else-if="instance.kind === 'transactions'">
<header class="widget-list-header">
<span>{{ phone.t('Home.widgetSystem.transactions.name') }}</span>
<Banknote :size="19" />
</header>
<button
v-for="transaction in visibleTransactions"
:key="transaction.id"
type="button"
class="widget-transaction"
data-widget-control
@click.stop="openWidget"
>
<span>
<strong>{{ transaction.label }}</strong>
<small>{{ transaction.reference }}</small>
</span>
<b
:class="{
positive:
transaction.kind === 'deposit' ||
transaction.kind === 'transfer_in',
}"
>{{
transaction.kind === 'deposit' ||
transaction.kind === 'transfer_in'
? '+'
: ''
}}{{ formatMoney(transaction.amount) }}</b
>
</button>
</template>
<template v-else-if="instance.kind === 'contacts'">
<header class="widget-list-header">
<span>{{ phone.t('Home.widgetSystem.contacts.name') }}</span>
</header>
<div class="widget-contacts">
<article v-for="contact in favoriteContacts" :key="contact.id">
<span class="widget-contact-avatar">{{
avatar(contact.name)
}}</span>
<strong>{{ contact.name }}</strong>
<div data-widget-control>
<button
type="button"
:aria-label="phone.t('Apps.messages.call')"
@click.stop="callContact(contact.phone_number)"
>
<Phone :size="15" fill="currentColor" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.messages.messageAction')"
@click.stop="messageContact(contact.phone_number)"
>
<MessageCircle :size="15" fill="currentColor" />
</button>
</div>
</article>
</div>
</template>
</k-glass>
<button
v-if="editMode && !preview"
class="home-widget-remove"
type="button"
:aria-label="phone.t('Home.widgetSystem.remove')"
@click.stop="emit('remove')"
@pointerdown.stop
>
<k-badge :colors="removeBadgeColors"></k-badge>
</button>
</div>
</template>
<style scoped>
.home-widget-shell {
position: relative;
z-index: 2;
min-width: 0;
min-height: 0;
transition:
transform 0.32s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.2s ease;
}
.home-widget-shell--dragging {
z-index: 40;
opacity: 0.86;
transition:
transform var(--springboard-page-duration) var(--springboard-page-easing),
opacity 0.2s ease;
pointer-events: none;
}
.home-widget-shell--editing:not(.home-widget-shell--dragging) {
animation: widget-wobble 0.17s ease-in-out infinite alternate;
}
.home-widget-shell--preview {
width: 100%;
aspect-ratio: 2 / 1;
}
.home-widget-shell--preview.home-widget-shell--small {
max-width: 150px;
aspect-ratio: 1;
}
.home-widget-shell--preview.home-widget-shell--large {
aspect-ratio: 1;
}
.home-widget {
width: 100%;
height: 100%;
padding: 14px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 22px;
outline: none;
color: #fff;
background: rgb(28 28 30 / 76%);
box-shadow:
0 8px 24px rgb(0 0 0 / 24%),
inset 0 0.5px rgb(255 255 255 / 18%);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.home-widget:active {
filter: brightness(1.08);
}
.home-widget small,
.widget-eyebrow {
color: rgb(255 255 255 / 68%);
font-size: 11px;
line-height: 1.25;
}
.home-widget--clock,
.home-widget--date,
.home-widget--wallet {
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: flex-end;
}
.widget-clock {
margin: 1px 0;
font-size: 31px;
font-weight: 400;
letter-spacing: -1.6px;
line-height: 1;
}
.home-widget-shell--medium .widget-clock {
font-size: 45px;
}
.home-widget--date {
background: rgb(247 247 247 / 88%);
color: #111;
}
.home-widget--date small {
color: #6e6e73;
}
.widget-date-month {
color: #ff3b30;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.widget-date-day {
font-size: 48px;
font-weight: 300;
letter-spacing: -2px;
line-height: 0.95;
}
.home-widget--weather {
display: flex;
flex-direction: column;
justify-content: space-between;
background: rgb(25 69 112 / 82%);
}
.widget-weather-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.widget-weather-top > div {
display: flex;
flex-direction: column;
}
.widget-weather-top strong {
font-size: 34px;
font-weight: 300;
letter-spacing: -1.5px;
line-height: 1;
}
.widget-weather-range {
margin-top: 4px;
}
.home-widget--music {
display: grid;
align-items: center;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
}
.home-widget-shell--large .home-widget--music {
align-content: center;
grid-template-columns: 1fr;
text-align: center;
}
.widget-album {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border-radius: 13px;
color: #fff;
background: #b33a3a;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
font-size: 13px;
font-weight: 800;
letter-spacing: 1.5px;
}
.home-widget-shell--large .widget-album {
width: 118px;
height: 118px;
margin: 0 auto;
border-radius: 24px;
font-size: 20px;
}
.widget-music-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.widget-music-copy strong,
.widget-music-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-music-controls {
display: flex;
align-items: center;
gap: 5px;
}
.widget-music-controls button,
.widget-contacts button {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(255 255 255 / 13%);
}
.widget-wallet-icon {
position: absolute;
top: 13px;
right: 13px;
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 11px;
color: #0a84ff;
background: rgb(10 132 255 / 15%);
}
.widget-balance {
margin: 2px 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -1.2px;
}
.home-widget--transactions,
.home-widget--contacts {
display: flex;
flex-direction: column;
}
.widget-list-header {
display: flex;
margin-bottom: 7px;
align-items: center;
justify-content: space-between;
color: rgb(255 255 255 / 72%);
font-size: 12px;
font-weight: 600;
}
.widget-transaction {
display: flex;
min-height: 43px;
padding: 5px 0;
align-items: center;
justify-content: space-between;
border: 0;
border-top: 0.5px solid rgb(255 255 255 / 12%);
color: #fff;
background: transparent;
text-align: left;
}
.widget-transaction > span {
display: flex;
min-width: 0;
flex-direction: column;
}
.widget-transaction strong {
font-size: 12px;
}
.widget-transaction b {
color: #fff;
font-size: 12px;
font-weight: 600;
}
.widget-transaction b.positive {
color: #30d158;
}
.widget-contacts {
display: grid;
flex: 1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.home-widget-shell--large .widget-contacts {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(2, 1fr);
}
.widget-contacts article {
display: flex;
min-width: 0;
align-items: center;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.widget-contact-avatar {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 50%;
color: #fff;
background: #5e5ce6;
font-size: 16px;
font-weight: 650;
}
.widget-contacts article:nth-child(2n) .widget-contact-avatar {
background: #ff9f0a;
}
.widget-contacts article:nth-child(3n) .widget-contact-avatar {
background: #34c759;
}
.widget-contacts strong {
max-width: 100%;
overflow: hidden;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-contacts article > div {
display: flex;
gap: 3px;
}
.widget-contacts button {
width: 25px;
height: 25px;
color: #0a84ff;
background: rgb(10 132 255 / 14%);
}
.home-widget-remove {
position: absolute;
z-index: 8;
top: -8px;
left: -8px;
display: grid;
width: 25px;
height: 25px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
}
.home-widget-remove :deep(.k-badge) {
width: 23px;
height: 23px;
min-width: 23px;
padding: 0;
border: 0.5px solid rgb(255 255 255 / 55%);
box-shadow: 0 1px 5px rgb(0 0 0 / 55%);
font-size: 20px;
font-weight: 400;
}
@keyframes widget-wobble {
from {
transform: rotate(-0.7deg) translateY(-0.5px);
}
to {
transform: rotate(0.7deg) translateY(0.5px);
}
}
@media (prefers-reduced-motion: reduce) {
.home-widget-shell--editing {
animation: none;
}
}
</style>