mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 17:28:56 +00:00
1423 lines
34 KiB
Vue
1423 lines
34 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
Banknote,
|
||
MessageCircle,
|
||
Music2,
|
||
Pause,
|
||
Phone,
|
||
Play,
|
||
SkipForward,
|
||
WalletCards,
|
||
} from 'lucide-vue-next'
|
||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
import WeatherConditionIcon from '@/components/WeatherConditionIcon.vue'
|
||
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
|
||
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 { SkyWidgetFrame } from '@/ui'
|
||
import {
|
||
reorderDirectionFromKeyboard,
|
||
type ReorderDirection,
|
||
} from '@/utils/keyboard'
|
||
import {
|
||
springboardPageDragCompensation,
|
||
springboardSwipeIntent,
|
||
} from '@/utils/springboardDrag'
|
||
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: []
|
||
reorder: [direction: ReorderDirection]
|
||
}>()
|
||
|
||
const phone = usePhoneStore()
|
||
const calls = useCallsStore()
|
||
const messages = useMessagesStore()
|
||
const router = useRouter()
|
||
const usesClock = computed(() =>
|
||
['clock', 'date'].includes(props.instance.kind),
|
||
)
|
||
const usesBank = computed(() =>
|
||
['transactions', 'wallet'].includes(props.instance.kind),
|
||
)
|
||
const usesContacts = computed(() => props.instance.kind === 'contacts')
|
||
const clock = useClockService(usesClock)
|
||
const weather = useWeatherService()
|
||
const music = useMusicService()
|
||
const bank = useBankService(usesBank)
|
||
const contactsService = useContactsService(usesContacts)
|
||
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
|
||
let pointerTarget: HTMLElement | null = null
|
||
let pointerId: number | null = null
|
||
|
||
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}`,
|
||
transform: isDragging.value
|
||
? `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`
|
||
: undefined,
|
||
},
|
||
)
|
||
const dragPointerStyle = computed(() =>
|
||
isDragging.value
|
||
? {
|
||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0) 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 weatherConditionId = computed(
|
||
() => weather.forecast.value?.condition ?? 'partly_cloudy',
|
||
)
|
||
const weatherDayPhase = computed(() => {
|
||
const timestamp = weather.forecast.value?.timestamp
|
||
if (timestamp === undefined) return 'day'
|
||
const hour = new Date(timestamp).getUTCHours()
|
||
return hour >= 7 && hour < 20 ? 'day' : 'night'
|
||
})
|
||
const visibleHourlyWeather = computed(
|
||
() =>
|
||
weather.forecast.value?.hourly.slice(
|
||
0,
|
||
props.instance.size === 'large' ? 5 : 3,
|
||
) ?? [],
|
||
)
|
||
const widgetLabel = computed(() => {
|
||
const definition = WIDGET_REGISTRY_BY_KIND.get(props.instance.kind)
|
||
return phone.t(definition?.homeLabelKey ?? definition?.labelKey ?? '')
|
||
})
|
||
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,
|
||
),
|
||
)
|
||
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 formatForecastHour(timestamp: number): string {
|
||
return new Intl.DateTimeFormat(phone.lang, {
|
||
hour: '2-digit',
|
||
hourCycle: 'h23',
|
||
minute: '2-digit',
|
||
}).format(timestamp)
|
||
}
|
||
|
||
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
|
||
}
|
||
suppressClick.value = false
|
||
pointerTarget = event.currentTarget as HTMLElement
|
||
pointerId = event.pointerId
|
||
pointerTarget.setPointerCapture(pointerId)
|
||
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 (
|
||
springboardSwipeIntent(
|
||
event.clientX - pointerStart.x,
|
||
event.clientY - pointerStart.y,
|
||
) !== 'pending'
|
||
) {
|
||
suppressClick.value = true
|
||
clearHold()
|
||
}
|
||
}
|
||
|
||
function beginDrag(event: PointerEvent): void {
|
||
dragStartPage = phone.currentPage
|
||
dragPageWidth =
|
||
(event.currentTarget as HTMLElement)
|
||
.closest<HTMLElement>('.springboard-page')
|
||
?.getBoundingClientRect().width ?? 0
|
||
isDragging.value = true
|
||
emit('dragstart', event)
|
||
}
|
||
|
||
function onPointerUp(event: PointerEvent): void {
|
||
clearHold()
|
||
if (isDragging.value) {
|
||
suppressClick.value = true
|
||
emit('dragend', event)
|
||
isDragging.value = false
|
||
dragOffset.value = { x: 0, y: 0 }
|
||
}
|
||
releasePointerCapture()
|
||
}
|
||
|
||
function cancelDrag(): void {
|
||
clearHold()
|
||
const wasDragging = isDragging.value
|
||
isDragging.value = false
|
||
dragOffset.value = { x: 0, y: 0 }
|
||
releasePointerCapture()
|
||
if (wasDragging) emit('dragcancel')
|
||
}
|
||
|
||
function releasePointerCapture(): void {
|
||
if (
|
||
pointerTarget &&
|
||
pointerId !== null &&
|
||
pointerTarget.hasPointerCapture(pointerId)
|
||
) {
|
||
pointerTarget.releasePointerCapture(pointerId)
|
||
}
|
||
pointerTarget = null
|
||
pointerId = null
|
||
}
|
||
|
||
function onKeydown(event: KeyboardEvent): void {
|
||
if (!props.editMode) return
|
||
const direction = reorderDirectionFromKeyboard(event)
|
||
if (!direction) return
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
emit('reorder', direction)
|
||
}
|
||
|
||
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',
|
||
music: '/apps/music',
|
||
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()
|
||
releasePointerCapture()
|
||
})
|
||
</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"
|
||
>
|
||
<SkyWidgetFrame
|
||
class="home-widget-drag-surface"
|
||
:label="widgetLabel"
|
||
:show-label="!preview"
|
||
:size="instance.size"
|
||
:style="dragPointerStyle"
|
||
>
|
||
<article
|
||
class="home-widget"
|
||
:class="[
|
||
`home-widget--${instance.kind}`,
|
||
{
|
||
'phone-effect--solid-surface': instance.kind !== 'weather',
|
||
'home-widget--music-empty':
|
||
instance.kind === 'music' && !music.current.value,
|
||
},
|
||
]"
|
||
:data-weather-condition="
|
||
instance.kind === 'weather' ? weatherConditionId : undefined
|
||
"
|
||
:data-weather-period="
|
||
instance.kind === 'weather' ? weatherDayPhase : undefined
|
||
"
|
||
@click="openWidget"
|
||
@contextmenu.prevent
|
||
@lostpointercapture="cancelDrag"
|
||
@pointercancel="cancelDrag"
|
||
@pointerdown="onPointerDown"
|
||
@pointerleave="isDragging || clearHold()"
|
||
@pointermove="onPointerMove"
|
||
@pointerup="onPointerUp"
|
||
>
|
||
<button
|
||
v-if="interactive"
|
||
class="home-widget-open"
|
||
type="button"
|
||
:aria-label="widgetLabel"
|
||
:aria-keyshortcuts="
|
||
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
||
"
|
||
@click.stop="openWidget"
|
||
@keydown="onKeydown"
|
||
/>
|
||
<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'">
|
||
<div class="widget-date-header">
|
||
<span class="widget-date-weekday">{{ clock.weekday.value }}</span>
|
||
<time class="widget-date-time">{{ clock.time.value }}</time>
|
||
</div>
|
||
<strong class="widget-date-day">{{ clock.day.value }}</strong>
|
||
<small>{{ clock.month.value }}</small>
|
||
</template>
|
||
|
||
<template v-else-if="instance.kind === 'weather'">
|
||
<div v-if="weather.forecast.value" class="widget-weather-summary">
|
||
<span class="widget-weather-location">{{
|
||
weather.location.value
|
||
}}</span>
|
||
<strong class="widget-weather-temperature"
|
||
>{{ weather.forecast.value?.temperature ?? '--' }}°</strong
|
||
>
|
||
<div class="widget-weather-current">
|
||
<WeatherConditionIcon
|
||
:condition="weather.forecast.value.condition"
|
||
:timestamp="weather.forecast.value.timestamp"
|
||
:size="23"
|
||
/>
|
||
<div>
|
||
<small>{{ weather.condition.value }}</small>
|
||
<small class="widget-weather-range">
|
||
{{
|
||
phone.t('Home.widgetSystem.weather.range', {
|
||
high: String(forecastHigh ?? '--'),
|
||
low: String(forecastLow ?? '--'),
|
||
})
|
||
}}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="widget-weather-unavailable">
|
||
{{
|
||
phone.t(
|
||
weather.loading.value
|
||
? 'Common.loading'
|
||
: 'Apps.weather.unavailable',
|
||
)
|
||
}}
|
||
</div>
|
||
<div
|
||
v-if="weather.forecast.value && instance.size !== 'small'"
|
||
class="widget-weather-hourly"
|
||
>
|
||
<div v-for="hour in visibleHourlyWeather" :key="hour.timestamp">
|
||
<time>{{ formatForecastHour(hour.timestamp) }}</time>
|
||
<WeatherConditionIcon
|
||
:condition="hour.condition"
|
||
:timestamp="hour.timestamp"
|
||
:size="21"
|
||
/>
|
||
<strong>{{ hour.temperature }}°</strong>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="instance.kind === 'music'">
|
||
<div class="widget-album" aria-hidden="true">
|
||
<img
|
||
v-if="music.current.value?.artwork"
|
||
:src="music.current.value.artwork"
|
||
alt=""
|
||
draggable="false"
|
||
/>
|
||
<Music2 v-else :size="36" />
|
||
</div>
|
||
<div class="widget-music-body">
|
||
<Music2 class="widget-music-mark" :size="22" aria-hidden="true" />
|
||
<div v-if="music.current.value" class="widget-music-copy">
|
||
<strong>{{ music.current.value.title }}</strong>
|
||
<small>{{ music.current.value.artist }}</small>
|
||
<span class="widget-music-progress" aria-hidden="true">
|
||
<i :style="{ width: `${music.progress.value}%` }" />
|
||
</span>
|
||
</div>
|
||
<div v-else class="widget-music-placeholder" aria-hidden="true">
|
||
<span />
|
||
<span />
|
||
</div>
|
||
<div
|
||
v-if="music.current.value"
|
||
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"
|
||
aria-hidden="true"
|
||
/>
|
||
<Play
|
||
v-else
|
||
:size="20"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:aria-label="phone.t('ControlCenter.next')"
|
||
@click.stop="music.next"
|
||
>
|
||
<SkipForward
|
||
:size="19"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p v-if="!music.current.value" class="widget-music-empty">
|
||
{{ phone.t('Home.widgetSystem.music.empty') }}
|
||
</p>
|
||
</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>
|
||
</article>
|
||
</SkyWidgetFrame>
|
||
|
||
<button
|
||
v-if="editMode && !preview"
|
||
class="home-widget-remove"
|
||
type="button"
|
||
:aria-label="phone.t('Home.widgetSystem.remove')"
|
||
:style="dragPointerStyle"
|
||
@click.stop="emit('remove')"
|
||
@pointerdown.stop
|
||
>
|
||
<span aria-hidden="true">−</span>
|
||
</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 {
|
||
--phone-effect-solid-background: #19191b;
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
height: 100%;
|
||
padding: 15px;
|
||
overflow: hidden;
|
||
border: 0.75px solid rgb(255 255 255 / 17%);
|
||
border-radius: var(--sky-widget-radius-medium);
|
||
outline: none;
|
||
color: #fff;
|
||
background: rgb(25 25 27 / 91%);
|
||
box-shadow:
|
||
0 10px 25px rgb(0 0 0 / 28%),
|
||
inset 0 0.75px rgb(255 255 255 / 15%);
|
||
backdrop-filter: blur(26px) saturate(125%);
|
||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||
cursor: pointer;
|
||
font-family:
|
||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||
'Segoe UI', sans-serif;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
touch-action: none;
|
||
}
|
||
|
||
.home-widget-shell--small .home-widget {
|
||
padding: 13px;
|
||
border-radius: var(--sky-widget-radius-small);
|
||
}
|
||
|
||
.home-widget-shell--large .home-widget {
|
||
padding: 18px;
|
||
border-radius: var(--sky-widget-radius-large);
|
||
}
|
||
|
||
.home-widget:active {
|
||
filter: brightness(1.08);
|
||
}
|
||
|
||
.home-widget-open {
|
||
position: absolute;
|
||
z-index: 1;
|
||
inset: 0;
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: inherit;
|
||
outline: none;
|
||
color: transparent;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
appearance: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.home-widget-open:focus-visible {
|
||
box-shadow: inset 0 0 0 2px var(--sky-app-accent, #0a84ff);
|
||
}
|
||
|
||
.home-widget [data-widget-control] {
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
.home-widget small,
|
||
.widget-eyebrow {
|
||
color: rgb(255 255 255 / 62%);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
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;
|
||
}
|
||
|
||
.home-widget--clock {
|
||
background: rgb(12 12 13 / 94%);
|
||
}
|
||
|
||
.widget-clock {
|
||
margin: 2px 0;
|
||
font-size: 28px;
|
||
font-weight: 500;
|
||
letter-spacing: -1.8px;
|
||
line-height: 1;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.home-widget-shell--medium .widget-clock {
|
||
font-size: 48px;
|
||
}
|
||
|
||
.home-widget--date {
|
||
justify-content: flex-start;
|
||
background: rgb(24 24 26 / 94%);
|
||
color: #fff;
|
||
}
|
||
|
||
.home-widget--date small {
|
||
margin-top: auto;
|
||
color: rgb(255 255 255 / 58%);
|
||
}
|
||
|
||
.widget-date-header {
|
||
display: flex;
|
||
width: 100%;
|
||
min-width: 0;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.widget-date-weekday,
|
||
.widget-date-time {
|
||
color: #ff3b30;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
.widget-date-weekday {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.widget-date-time {
|
||
flex: none;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.1px;
|
||
}
|
||
|
||
.widget-date-day {
|
||
font-size: 52px;
|
||
font-weight: 400;
|
||
letter-spacing: -2px;
|
||
line-height: 0.95;
|
||
}
|
||
|
||
.home-widget--weather {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
color: #fff;
|
||
background:
|
||
radial-gradient(
|
||
circle at 72% 13%,
|
||
rgb(255 232 132 / 40%) 0 7%,
|
||
transparent 27%
|
||
),
|
||
linear-gradient(170deg, #2878bb 0%, #274f8d 48%, #14233f 100%);
|
||
transition: background 0.35s ease;
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='sunny'] {
|
||
background:
|
||
radial-gradient(
|
||
circle at 75% 15%,
|
||
rgb(255 232 132 / 48%) 0 7%,
|
||
transparent 29%
|
||
),
|
||
linear-gradient(170deg, #2878bb 0%, #274f8d 48%, #14233f 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='clear'] {
|
||
background:
|
||
radial-gradient(
|
||
circle at 77% 19%,
|
||
rgb(191 207 255 / 13%) 0 2%,
|
||
transparent 3%
|
||
),
|
||
radial-gradient(
|
||
circle at 24% 32%,
|
||
rgb(255 255 255 / 27%) 0 1px,
|
||
transparent 2px
|
||
),
|
||
linear-gradient(165deg, #15183c 0%, #202c61 49%, #11172f 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='clear'][data-weather-period='day'] {
|
||
background:
|
||
radial-gradient(
|
||
circle at 75% 15%,
|
||
rgb(255 232 132 / 38%) 0 6%,
|
||
transparent 27%
|
||
),
|
||
linear-gradient(170deg, #2878bb 0%, #274f8d 48%, #14233f 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='partly_cloudy'] {
|
||
background:
|
||
radial-gradient(
|
||
ellipse at 72% 22%,
|
||
rgb(255 255 255 / 33%),
|
||
transparent 34%
|
||
),
|
||
linear-gradient(165deg, #506f91 0%, #354b6e 48%, #17233e 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='cloudy'] {
|
||
background:
|
||
radial-gradient(
|
||
ellipse at 72% 22%,
|
||
rgb(255 255 255 / 24%),
|
||
transparent 34%
|
||
),
|
||
linear-gradient(165deg, #536b82 0%, #34485e 48%, #172535 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='rain'] {
|
||
background:
|
||
repeating-linear-gradient(
|
||
104deg,
|
||
transparent 0 22px,
|
||
rgb(188 236 255 / 9%) 23px 25px,
|
||
transparent 26px 49px
|
||
),
|
||
linear-gradient(165deg, #354b69 0%, #253952 46%, #101b2d 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='thunder'] {
|
||
background:
|
||
radial-gradient(circle at 75% 18%, rgb(220 233 255 / 45%), transparent 23%),
|
||
linear-gradient(165deg, #27334d 0%, #2d3155 48%, #11172b 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='fog'] {
|
||
background:
|
||
linear-gradient(180deg, rgb(217 225 229 / 40%) 0 20%, transparent 55%),
|
||
linear-gradient(165deg, #596b75 0%, #40525e 48%, #21303b 100%);
|
||
}
|
||
|
||
.home-widget--weather[data-weather-condition='snow'] {
|
||
background:
|
||
radial-gradient(
|
||
circle at 20% 22%,
|
||
rgb(255 255 255 / 60%) 0 2px,
|
||
transparent 3px
|
||
),
|
||
radial-gradient(
|
||
circle at 70% 36%,
|
||
rgb(255 255 255 / 47%) 0 2px,
|
||
transparent 3px
|
||
),
|
||
linear-gradient(165deg, #55738e 0%, #3d5873 48%, #23364f 100%);
|
||
}
|
||
|
||
.widget-weather-summary {
|
||
display: flex;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.widget-weather-location {
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
line-height: 16px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.widget-weather-temperature {
|
||
margin-top: 1px;
|
||
font-size: 46px;
|
||
font-weight: 400;
|
||
letter-spacing: -2.2px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.widget-weather-current {
|
||
display: flex;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
margin-top: auto;
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.widget-weather-current > div {
|
||
display: flex;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.widget-weather-current :deep(.weather-condition-icon) {
|
||
flex: none;
|
||
}
|
||
|
||
.widget-weather-current small {
|
||
overflow: hidden;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
line-height: 15px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.widget-weather-range {
|
||
color: rgb(255 255 255 / 82%) !important;
|
||
font-size: 10px !important;
|
||
font-weight: 550 !important;
|
||
line-height: 13px !important;
|
||
}
|
||
|
||
.widget-weather-unavailable {
|
||
display: grid;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
place-items: center;
|
||
color: rgb(255 255 255 / 72%);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
}
|
||
|
||
.widget-weather-hourly {
|
||
display: grid;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
border-left: 0.5px solid rgb(255 255 255 / 20%);
|
||
}
|
||
|
||
.widget-weather-hourly > div {
|
||
display: grid;
|
||
min-width: 0;
|
||
align-content: center;
|
||
justify-items: center;
|
||
gap: 7px;
|
||
}
|
||
|
||
.widget-weather-hourly time {
|
||
color: rgb(255 255 255 / 58%);
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.widget-weather-hourly strong {
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.home-widget-shell--medium .home-widget--weather {
|
||
padding: 13px 15px;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
.home-widget-shell--medium .widget-weather-temperature {
|
||
font-size: 42px;
|
||
}
|
||
|
||
.home-widget-shell--medium .widget-weather-hourly {
|
||
padding-left: 10px;
|
||
}
|
||
|
||
.home-widget-shell--medium .widget-weather-hourly svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
}
|
||
|
||
.home-widget-shell--medium .widget-weather-hourly time {
|
||
font-size: 8px;
|
||
}
|
||
|
||
.home-widget-shell--medium .widget-weather-hourly strong {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.home-widget-shell--large .home-widget--weather {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-weather-temperature {
|
||
font-size: 62px;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-weather-current {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-weather-hourly {
|
||
margin-top: auto;
|
||
padding-top: 16px;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
border-top: 0.5px solid rgb(255 255 255 / 20%);
|
||
border-left: 0;
|
||
}
|
||
|
||
.home-widget--music {
|
||
display: grid;
|
||
position: relative;
|
||
align-items: center;
|
||
grid-template-columns: 112px minmax(0, 1fr);
|
||
gap: 14px;
|
||
background: rgb(39 39 42 / 95%);
|
||
}
|
||
|
||
.home-widget-shell--large .home-widget--music {
|
||
align-content: start;
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: 172px minmax(0, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.widget-album {
|
||
display: grid;
|
||
width: 100%;
|
||
height: auto;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
place-items: center;
|
||
align-self: center;
|
||
aspect-ratio: 1;
|
||
border: 0.5px solid rgb(255 255 255 / 14%);
|
||
border-radius: 16px;
|
||
color: rgb(255 255 255 / 48%);
|
||
background: rgb(255 255 255 / 8%);
|
||
box-shadow:
|
||
inset 0 0 0 0.5px rgb(255 255 255 / 5%),
|
||
0 5px 13px rgb(0 0 0 / 18%);
|
||
}
|
||
|
||
.widget-album img {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-album {
|
||
width: 172px;
|
||
height: 172px;
|
||
margin: 0 auto;
|
||
border-radius: 25px;
|
||
}
|
||
|
||
.widget-music-body {
|
||
display: flex;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
height: 100%;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.widget-music-mark {
|
||
align-self: flex-end;
|
||
flex: none;
|
||
color: #ff375f;
|
||
}
|
||
|
||
.widget-music-copy {
|
||
display: flex;
|
||
min-width: 0;
|
||
margin-top: auto;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.widget-music-copy strong,
|
||
.widget-music-copy small {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.widget-music-copy strong {
|
||
font-size: 14px;
|
||
line-height: 18px;
|
||
}
|
||
|
||
.widget-music-progress {
|
||
width: 100%;
|
||
height: 3px;
|
||
margin-top: 9px;
|
||
overflow: hidden;
|
||
border-radius: 3px;
|
||
background: rgb(255 255 255 / 12%);
|
||
}
|
||
|
||
.widget-music-progress i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: #ff375f;
|
||
}
|
||
|
||
.widget-music-empty {
|
||
position: absolute;
|
||
right: 15px;
|
||
bottom: 11px;
|
||
left: 15px;
|
||
z-index: 0;
|
||
margin: 0;
|
||
color: rgb(255 255 255 / 72%);
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
line-height: 17px;
|
||
text-align: center;
|
||
}
|
||
|
||
.widget-music-placeholder {
|
||
display: grid;
|
||
margin: auto 0;
|
||
gap: 10px;
|
||
}
|
||
|
||
.widget-music-placeholder span {
|
||
display: block;
|
||
height: 7px;
|
||
border-radius: 7px;
|
||
background: rgb(255 255 255 / 8%);
|
||
}
|
||
|
||
.widget-music-placeholder span:first-child {
|
||
width: 54%;
|
||
background: rgb(255 55 95 / 14%);
|
||
}
|
||
|
||
.widget-music-controls {
|
||
display: flex;
|
||
margin-top: auto;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 2px;
|
||
}
|
||
|
||
.widget-music-controls button,
|
||
.widget-contacts button {
|
||
display: grid;
|
||
width: 44px;
|
||
height: 44px;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
color: #fff;
|
||
background: rgb(255 255 255 / 11%);
|
||
}
|
||
|
||
.home-widget-shell--large .widget-music-body {
|
||
text-align: center;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-music-mark {
|
||
position: absolute;
|
||
top: 18px;
|
||
right: 18px;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-music-copy,
|
||
.home-widget-shell--large .widget-music-empty {
|
||
margin-right: auto;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.home-widget-shell--large .widget-music-controls {
|
||
justify-content: center;
|
||
}
|
||
|
||
.home-widget--wallet {
|
||
background: rgb(25 26 29 / 94%);
|
||
}
|
||
|
||
.widget-wallet-icon {
|
||
position: absolute;
|
||
top: 13px;
|
||
right: 13px;
|
||
display: grid;
|
||
width: 36px;
|
||
height: 36px;
|
||
place-items: center;
|
||
border-radius: 11px;
|
||
color: #64d2ff;
|
||
background: rgb(100 210 255 / 13%);
|
||
}
|
||
|
||
.widget-balance {
|
||
margin: 2px 0;
|
||
font-size: 30px;
|
||
font-weight: 650;
|
||
letter-spacing: -1.2px;
|
||
}
|
||
|
||
.home-widget--transactions,
|
||
.home-widget--contacts {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.home-widget--transactions {
|
||
background: rgb(29 29 31 / 94%);
|
||
}
|
||
|
||
.home-widget--contacts {
|
||
background: rgb(25 26 29 / 94%);
|
||
}
|
||
|
||
.widget-list-header {
|
||
display: flex;
|
||
margin-bottom: 7px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
color: #64d2ff;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.home-widget--transactions .widget-list-header {
|
||
color: #30d158;
|
||
}
|
||
|
||
.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 / 11%);
|
||
color: #fff;
|
||
background: transparent;
|
||
text-align: left;
|
||
}
|
||
|
||
.widget-transaction > span {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.widget-transaction strong {
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.widget-transaction small {
|
||
margin-top: 1px;
|
||
font-size: 9px;
|
||
}
|
||
|
||
.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;
|
||
box-shadow: inset 0 1px rgb(255 255 255 / 20%);
|
||
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: #64d2ff;
|
||
background: rgb(100 210 255 / 12%);
|
||
}
|
||
|
||
.home-widget-remove {
|
||
position: absolute;
|
||
z-index: 8;
|
||
top: -18px;
|
||
left: -18px;
|
||
display: grid;
|
||
width: 44px;
|
||
height: 44px;
|
||
padding: 0;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
}
|
||
|
||
.home-widget-remove > span {
|
||
display: grid;
|
||
width: 23px;
|
||
height: 23px;
|
||
min-width: 23px;
|
||
padding: 0;
|
||
place-items: center;
|
||
border: 0.5px solid rgb(255 255 255 / 55%);
|
||
border-radius: 50%;
|
||
color: #111;
|
||
background: #d1d1d6;
|
||
box-shadow: 0 1px 5px rgb(0 0 0 / 55%);
|
||
font-size: 20px;
|
||
font-weight: 400;
|
||
line-height: 1;
|
||
}
|
||
|
||
@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>
|