mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 06:01:35 +00:00
299 lines
9.0 KiB
Vue
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>
|