Files
sky_phone/frontend/src/components/SpringboardWidget.vue
T
2026-08-08 22:48:42 +02:00

832 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>