Files
sky_phone-sky-systems/frontend/src/views/apps/WeatherApp.vue
T
2026-08-17 22:19:43 +02:00

299 lines
9.0 KiB
Vue

<script setup lang="ts">
import {
CloudSun,
Droplets,
Gauge,
Navigation,
ThermometerSun,
Umbrella,
Wind,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import WeatherConditionIcon from '@/components/WeatherConditionIcon.vue'
import { usePullToRefresh } from '@/composables/usePullToRefresh'
import { usePhoneStore } from '@/stores/phone'
import { useWeatherStore } from '@/stores/weather'
import type { WeatherConditionId } from '@/types/weather'
import {
SkyAppPage,
SkyCard,
SkyLink,
SkyNavbar,
SkyScrollArea,
SkySpinner,
SkyNotification,
} from '@/ui'
const phone = usePhoneStore()
const weather = useWeatherStore()
const forecast = computed(() => weather.forecast)
const cooldownToastOpened = ref(false)
let cooldownToastTimer: ReturnType<typeof setTimeout> | undefined
const {
finishPull,
movePull,
pullDistance,
pullThreshold,
pullWithWheel,
startPull,
} = usePullToRefresh({
isAtTop: (event) =>
(event.currentTarget as HTMLElement | null)?.scrollTop === 0,
isBusy: () => weather.isLoading,
refresh: () => weather.refresh(true, true),
})
function closeCooldownToast(): void {
cooldownToastOpened.value = false
if (weather.error === 'reload_cooldown') weather.error = null
}
const isNight = computed(() => {
if (!forecast.value) return false
const hour = new Date(forecast.value.timestamp).getUTCHours()
return hour < 6 || hour >= 20
})
const rainy = computed(
() =>
forecast.value?.condition === 'rain' ||
forecast.value?.condition === 'thunder',
)
const rainDrops = [
['3%', '11px', '1.35s', '-0.3s', '0.32'],
['9%', '8px', '1.7s', '-1.1s', '0.2'],
['15%', '14px', '1.45s', '-0.8s', '0.38'],
['21%', '10px', '1.9s', '-1.6s', '0.24'],
['28%', '13px', '1.55s', '-0.2s', '0.3'],
['35%', '8px', '1.75s', '-1.35s', '0.18'],
['42%', '16px', '1.4s', '-0.65s', '0.36'],
['49%', '10px', '2s', '-1.8s', '0.22'],
['56%', '14px', '1.6s', '-0.45s', '0.34'],
['63%', '9px', '1.8s', '-1.2s', '0.2'],
['70%', '15px', '1.5s', '-0.9s', '0.37'],
['77%', '8px', '1.95s', '-1.55s', '0.2'],
['84%', '12px', '1.45s', '-0.15s', '0.31'],
['91%', '10px', '1.7s', '-1.05s', '0.24'],
['97%', '14px', '1.55s', '-0.6s', '0.28'],
].map(([left, height, duration, delay, opacity]) => ({
'--rain-delay': delay,
'--rain-duration': duration,
'--rain-height': height,
'--rain-left': left,
'--rain-opacity': opacity,
}))
const snowFlakes = [
['4%', '1.8s', '-1.2s', '0.4'],
['12%', '2.6s', '-0.5s', '0.65'],
['19%', '2.1s', '-1.7s', '0.45'],
['27%', '2.9s', '-0.9s', '0.7'],
['36%', '2.3s', '-1.4s', '0.55'],
['44%', '3.1s', '-0.2s', '0.45'],
['53%', '2s', '-1.8s', '0.7'],
['62%', '2.7s', '-1.1s', '0.48'],
['70%', '2.2s', '-0.7s', '0.6'],
['78%', '3s', '-1.6s', '0.42'],
['87%', '2.4s', '-0.3s', '0.68'],
['95%', '2.8s', '-1.3s', '0.5'],
].map(([left, duration, delay, opacity]) => ({
'--snow-delay': delay,
'--snow-duration': duration,
'--snow-left': left,
'--snow-opacity': opacity,
}))
const fogLayers = [
['-18s', '44s', '0.28', '12%'],
['-7s', '38s', '0.18', '34%'],
['-27s', '51s', '0.23', '57%'],
].map(([delay, duration, opacity, top]) => ({
'--fog-delay': delay,
'--fog-duration': duration,
'--fog-opacity': opacity,
'--fog-top': top,
}))
function conditionLabel(condition: WeatherConditionId): string {
return phone.t(`Apps.weather.conditions.${condition}`)
}
function formatHour(timestamp: number, index: number): string {
if (index === 0) return phone.t('Apps.weather.now')
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
}).format(timestamp)
}
onBeforeUnmount(() => {
if (cooldownToastTimer) clearTimeout(cooldownToastTimer)
})
watch(
() => weather.error,
(error) => {
if (error !== 'reload_cooldown') return
if (cooldownToastTimer) clearTimeout(cooldownToastTimer)
cooldownToastOpened.value = true
cooldownToastTimer = setTimeout(closeCooldownToast, 2800)
},
)
</script>
<template>
<SkyAppPage
class="weather-app"
:class="[
forecast ? `weather-app--${forecast.condition}` : '',
{ 'weather-app--night': isNight },
]"
:label="phone.t('Apps.weather.name')"
dark
>
<div class="weather-app__backdrop" aria-hidden="true"></div>
<div v-if="rainy" class="weather-app__rain" aria-hidden="true">
<i v-for="(drop, index) in rainDrops" :key="index" :style="drop"></i>
</div>
<div
v-if="forecast?.condition === 'thunder'"
class="weather-app__lightning"
aria-hidden="true"
></div>
<div
v-if="forecast?.condition === 'fog'"
class="weather-app__fog"
aria-hidden="true"
>
<i v-for="(layer, index) in fogLayers" :key="index" :style="layer"></i>
</div>
<div
v-if="forecast?.condition === 'snow'"
class="weather-app__snow"
aria-hidden="true"
>
<i v-for="(flake, index) in snowFlakes" :key="index" :style="flake"></i>
</div>
<div
v-if="forecast?.condition === 'sunny'"
class="weather-app__sun-glow"
aria-hidden="true"
></div>
<div v-if="isNight" class="weather-app__stars" aria-hidden="true"></div>
<SkyNavbar class="weather-navbar" :title="phone.t('Apps.weather.name')" />
<SkyScrollArea
v-if="forecast"
class="weather-scroll"
@touchend="finishPull"
@touchmove.passive="movePull"
@touchstart.passive="startPull"
@wheel="pullWithWheel"
>
<div
class="weather-pull-refresh"
:class="{ 'is-visible': pullDistance > 0 }"
:style="{ transform: `translateY(${pullDistance - pullThreshold}px)` }"
aria-live="polite"
>
<SkySpinner :label="phone.t('Common.loading')" />
</div>
<header class="weather-hero">
<div class="weather-location">
<Navigation :size="13" fill="currentColor" />
{{ phone.t(`Apps.weather.regions.${forecast.region}`) }}
</div>
<WeatherConditionIcon
:condition="forecast.condition"
:timestamp="forecast.timestamp"
class="weather-hero__icon"
:size="82"
/>
<div class="weather-temperature">{{ forecast.temperature }}°</div>
<strong>{{ conditionLabel(forecast.condition) }}</strong>
<p>{{ phone.t(`Apps.weather.summaries.${forecast.condition}`) }}</p>
</header>
<p
v-if="weather.error && weather.error !== 'reload_cooldown'"
class="weather-stale"
role="status"
>
{{ phone.t('Apps.weather.stale') }}
</p>
<section
class="weather-details"
:aria-label="phone.t('Apps.weather.details')"
>
<SkyCard :content-wrap="false" class="weather-detail-card">
<ThermometerSun :size="18" />
<span>{{ phone.t('Apps.weather.feelsLike') }}</span>
<strong>{{ forecast.feelsLike }}°</strong>
</SkyCard>
<SkyCard :content-wrap="false" class="weather-detail-card">
<Wind :size="18" />
<span>{{ phone.t('Apps.weather.wind') }}</span>
<strong>{{ forecast.windSpeed }} km/h</strong>
</SkyCard>
<SkyCard :content-wrap="false" class="weather-detail-card">
<Droplets :size="18" />
<span>{{ phone.t('Apps.weather.humidity') }}</span>
<strong>{{ forecast.humidity }}%</strong>
</SkyCard>
<SkyCard :content-wrap="false" class="weather-detail-card">
<Umbrella :size="18" />
<span>{{ phone.t('Apps.weather.rain') }}</span>
<strong>{{ forecast.rainChance }}%</strong>
</SkyCard>
</section>
<SkyCard :content-wrap="false" class="weather-panel weather-hourly-panel">
<h2><Gauge :size="15" />{{ phone.t('Apps.weather.hourly') }}</h2>
<div class="weather-hourly">
<div
v-for="(hour, index) in forecast.hourly"
:key="hour.timestamp"
class="weather-hour"
>
<span>{{ formatHour(hour.timestamp, index) }}</span>
<WeatherConditionIcon
:condition="hour.condition"
:timestamp="hour.timestamp"
/>
<small v-if="hour.rainChance >= 30">{{ hour.rainChance }}%</small>
<strong>{{ hour.temperature }}°</strong>
</div>
</div>
</SkyCard>
</SkyScrollArea>
<div v-else class="weather-empty">
<SkySpinner v-if="weather.isLoading" :label="phone.t('Common.loading')" />
<CloudSun v-else :size="52" :stroke-width="1.4" />
<strong>{{
phone.t(
weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable',
)
}}</strong>
<SkyLink
v-if="!weather.isLoading"
component="button"
@click="weather.refresh(true, true)"
>
{{ phone.t('Apps.weather.tryAgain') }}
</SkyLink>
</div>
<SkyNotification
:opened="cooldownToastOpened"
:text="phone.t('Apps.weather.errors.reload_cooldown')"
@click="closeCooldownToast"
/>
</SkyAppPage>
</template>
<style scoped>
.weather-scroll {
padding: 4px 14px 24px;
}
</style>