mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 01:03:25 +00:00
ADD - weather app
This commit is contained in:
@@ -25,6 +25,7 @@ import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import {
|
||||
useNotificationsStore,
|
||||
type PhoneNotificationInput,
|
||||
@@ -69,6 +70,7 @@ const calls = useCallsStore()
|
||||
const mail = useMailStore()
|
||||
const media = useMediaStore()
|
||||
const notes = useNotesStore()
|
||||
const weather = useWeatherStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -286,11 +288,13 @@ watch(
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
if (cameraTimer !== undefined) window.clearTimeout(cameraTimer)
|
||||
if (!isOpen) {
|
||||
weather.stop()
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
return
|
||||
}
|
||||
isLocked.value = true
|
||||
weather.start()
|
||||
isUnlocking.value = false
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.replace('/')
|
||||
@@ -298,6 +302,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
weather.stop()
|
||||
if (clockTicker) clearInterval(clockTicker)
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
if (cameraTimer !== undefined) window.clearTimeout(cameraTimer)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -745,7 +745,20 @@ button {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
border-color: #ffffff22;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(145deg, #299cf0bb, #6739d3aa);
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
filter 0.18s ease;
|
||||
}
|
||||
.widget--weather:active {
|
||||
transform: scale(0.985);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.widget--weather div {
|
||||
display: flex;
|
||||
@@ -1022,6 +1035,305 @@ button {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.weather-app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: #14264a;
|
||||
color: #fff;
|
||||
}
|
||||
.weather-app__backdrop {
|
||||
position: absolute;
|
||||
z-index: -2;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 72% 13%, #ffe88480 0 7%, transparent 27%),
|
||||
linear-gradient(170deg, #318bd5 0%, #325ba0 43%, #152445 100%);
|
||||
transition: background 0.5s ease;
|
||||
}
|
||||
.weather-app__backdrop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 30%, #ffffff20, transparent 28%),
|
||||
linear-gradient(180deg, transparent 42%, #08112680 100%);
|
||||
}
|
||||
.weather-app--clear .weather-app__backdrop {
|
||||
background:
|
||||
radial-gradient(circle at 77% 19%, #bfcfff22 0 2%, transparent 3%),
|
||||
radial-gradient(circle at 24% 32%, #fff4 0 1px, transparent 2px),
|
||||
linear-gradient(165deg, #15183c 0%, #202c61 49%, #11172f 100%);
|
||||
}
|
||||
.weather-app--partly_cloudy .weather-app__backdrop,
|
||||
.weather-app--cloudy .weather-app__backdrop {
|
||||
background:
|
||||
radial-gradient(ellipse at 72% 22%, #fff5, transparent 34%),
|
||||
linear-gradient(165deg, #5c7899 0%, #384d70 48%, #17233e 100%);
|
||||
}
|
||||
.weather-app--rain .weather-app__backdrop,
|
||||
.weather-app--thunder .weather-app__backdrop {
|
||||
background:
|
||||
repeating-linear-gradient(104deg, transparent 0 22px, #bcecff17 23px 25px, transparent 26px 49px),
|
||||
linear-gradient(165deg, #354b69 0%, #253952 46%, #101b2d 100%);
|
||||
}
|
||||
.weather-app--thunder .weather-app__backdrop {
|
||||
background:
|
||||
radial-gradient(circle at 75% 18%, #dce9ff73, transparent 23%),
|
||||
linear-gradient(165deg, #27334d 0%, #2d3155 48%, #11172b 100%);
|
||||
}
|
||||
.weather-app--fog .weather-app__backdrop {
|
||||
background:
|
||||
linear-gradient(180deg, #d9e1e580 0 20%, transparent 55%),
|
||||
linear-gradient(165deg, #76858d 0%, #4d606d 48%, #24333f 100%);
|
||||
}
|
||||
.weather-app--snow .weather-app__backdrop {
|
||||
background:
|
||||
radial-gradient(circle at 20% 22%, #fff9 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 70% 36%, #fff7 0 2px, transparent 3px),
|
||||
linear-gradient(165deg, #7897b5 0%, #536d8b 48%, #283c58 100%);
|
||||
}
|
||||
.weather-navbar {
|
||||
--tw-bg-opacity: 0;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 46px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
background: transparent !important;
|
||||
color: #fff;
|
||||
}
|
||||
.weather-navbar::after {
|
||||
opacity: 0;
|
||||
}
|
||||
.weather-navbar button {
|
||||
color: #fff;
|
||||
}
|
||||
.weather-scroll {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 90px 0 25px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 4px 14px 24px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.weather-scroll::-webkit-scrollbar,
|
||||
.weather-hourly::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.weather-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 254px;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 9px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
text-shadow: 0 2px 8px #0008;
|
||||
}
|
||||
.weather-hero__icon {
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 10px 18px #07122760);
|
||||
}
|
||||
.weather-temperature {
|
||||
margin-top: -4px;
|
||||
font-size: 70px;
|
||||
font-weight: 220;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -5px;
|
||||
text-shadow: 0 6px 22px #07122755;
|
||||
}
|
||||
.weather-hero strong {
|
||||
margin-top: 7px;
|
||||
font-size: 19px;
|
||||
}
|
||||
.weather-hero p {
|
||||
max-width: 285px;
|
||||
margin: 4px 0 0;
|
||||
color: #ffffffe0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.weather-stale {
|
||||
margin: -4px 3px 10px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ffffff20;
|
||||
border-radius: 11px;
|
||||
background: #10182a66;
|
||||
color: #ffffffc9;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.weather-detail-card,
|
||||
.weather-panel {
|
||||
border: 1px solid #ffffff24 !important;
|
||||
background: #111b3378 !important;
|
||||
box-shadow:
|
||||
inset 0 1px #ffffff17,
|
||||
0 9px 26px #0712272b !important;
|
||||
backdrop-filter: blur(22px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(135%);
|
||||
}
|
||||
.weather-detail-card {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
gap: 2px 5px;
|
||||
min-height: 75px;
|
||||
margin: 0 !important;
|
||||
padding: 10px 11px;
|
||||
border-radius: 17px !important;
|
||||
}
|
||||
.weather-detail-card svg {
|
||||
grid-row: span 2;
|
||||
margin-top: 1px;
|
||||
color: #b9e7ff;
|
||||
}
|
||||
.weather-detail-card span {
|
||||
color: #ffffffa8;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.weather-detail-card strong {
|
||||
font-size: 17px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.weather-panel {
|
||||
margin: 0 0 12px !important;
|
||||
overflow: hidden;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
.weather-panel h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 11px 13px 8px;
|
||||
border-bottom: 1px solid #ffffff1f;
|
||||
color: #ffffffa8;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.weather-hourly {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 11px 9px 13px;
|
||||
scrollbar-width: none;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
.weather-hour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 52px;
|
||||
flex-direction: column;
|
||||
min-height: 104px;
|
||||
padding: 3px 2px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.weather-hour span {
|
||||
height: 21px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.weather-hour svg {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.weather-hour small {
|
||||
height: 14px;
|
||||
color: #66d2ff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.weather-hour strong {
|
||||
margin-top: auto;
|
||||
font-size: 15px;
|
||||
}
|
||||
.weather-day {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 55px 45px 28px 1fr 28px;
|
||||
min-height: 48px;
|
||||
margin: 0 12px;
|
||||
border-bottom: 1px solid #ffffff18;
|
||||
font-size: 13px;
|
||||
}
|
||||
.weather-day:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.weather-day > strong {
|
||||
font-weight: 650;
|
||||
}
|
||||
.weather-day__condition {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.weather-day__condition small {
|
||||
position: absolute;
|
||||
top: 29px;
|
||||
color: #66d2ff;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.weather-day__low {
|
||||
color: #ffffff7d;
|
||||
}
|
||||
.weather-day__range {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
margin: 0 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #07122755;
|
||||
}
|
||||
.weather-day__range i {
|
||||
position: absolute;
|
||||
left: var(--range-left);
|
||||
width: var(--range-width);
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #68d8ff, #7de4b7 55%, #ffd55e);
|
||||
}
|
||||
.weather-empty {
|
||||
position: absolute;
|
||||
inset: 90px 20px 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #ffffffd9;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-empty > svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.weather-spin {
|
||||
animation: weather-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes weather-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.app-header {
|
||||
padding: 17px 18px 8px;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BatteryCharging,
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
MoonStar,
|
||||
Pause,
|
||||
Play,
|
||||
Snowflake,
|
||||
SkipForward,
|
||||
Sun,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const weather = useWeatherStore()
|
||||
const router = useRouter()
|
||||
const now = ref(new Date())
|
||||
const playing = ref(false)
|
||||
let intervalId: number | undefined
|
||||
|
||||
const conditionIcons: Record<WeatherConditionId, Component> = {
|
||||
sunny: Sun,
|
||||
clear: MoonStar,
|
||||
partly_cloudy: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rain: CloudRain,
|
||||
thunder: CloudLightning,
|
||||
fog: CloudFog,
|
||||
snow: Snowflake,
|
||||
}
|
||||
const weatherIcon = computed(
|
||||
() => conditionIcons[weather.forecast?.condition ?? 'partly_cloudy'],
|
||||
)
|
||||
const weatherRegion = computed(() =>
|
||||
weather.forecast
|
||||
? phone.t(`Apps.weather.regions.${weather.forecast.region}`)
|
||||
: phone.t('Apps.weather.regions.los_santos'),
|
||||
)
|
||||
const weatherCondition = computed(() =>
|
||||
weather.forecast
|
||||
? phone.t(`Apps.weather.conditions.${weather.forecast.condition}`)
|
||||
: phone.t('Common.loading'),
|
||||
)
|
||||
const date = computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, {
|
||||
month: 'long',
|
||||
@@ -23,6 +58,11 @@ const date = computed(() =>
|
||||
)
|
||||
const day = computed(() => now.value.getDate())
|
||||
|
||||
function openWeather(): void {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/weather')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
@@ -36,14 +76,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="widgets-stack">
|
||||
<article class="widget widget--weather">
|
||||
<button
|
||||
type="button"
|
||||
class="widget widget--weather"
|
||||
:aria-label="phone.t('Apps.weather.name')"
|
||||
@click="openWeather"
|
||||
>
|
||||
<div>
|
||||
<span>{{ phone.t('Home.widgets.weather.city') }}</span>
|
||||
<strong>21°</strong>
|
||||
<small>{{ phone.t('Home.widgets.weather.condition') }}</small>
|
||||
<span>{{ weatherRegion }}</span>
|
||||
<strong>{{ weather.forecast?.temperature ?? '--' }}°</strong>
|
||||
<small>{{ weatherCondition }}</small>
|
||||
</div>
|
||||
<CloudSun :size="48" aria-hidden="true" />
|
||||
</article>
|
||||
<component :is="weatherIcon" :size="48" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="widget-row">
|
||||
<article class="widget widget--calendar">
|
||||
|
||||
@@ -17,10 +17,15 @@ describe('app registry', () => {
|
||||
route: '/apps/phone',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({
|
||||
gridOrder: 4,
|
||||
gridOrder: 5,
|
||||
labelKey: 'Apps.mail.name',
|
||||
route: '/apps/mail',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'weather')).toMatchObject({
|
||||
gridOrder: 4,
|
||||
labelKey: 'Apps.weather.name',
|
||||
route: '/apps/weather',
|
||||
})
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Phone,
|
||||
Settings,
|
||||
ShoppingBag,
|
||||
CloudSun,
|
||||
} from 'lucide-vue-next'
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
|
||||
@@ -22,6 +23,7 @@ import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
import photosIcon from '@/assets/img/app-icons/gallery.webp'
|
||||
import phoneIcon from '@/assets/img/app-icons/phone.webp'
|
||||
import settingsIcon from '@/assets/img/app-icons/settings.webp'
|
||||
import weatherIcon from '@/assets/img/app-icons/weather.webp'
|
||||
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
@@ -43,7 +45,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/MapApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 9,
|
||||
gridOrder: 10,
|
||||
icon: markRaw(MapPinned),
|
||||
iconClass: '',
|
||||
iconImage: mapIcon,
|
||||
@@ -56,7 +58,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 4,
|
||||
gridOrder: 5,
|
||||
icon: markRaw(Mail),
|
||||
iconClass: '',
|
||||
iconImage: mailIcon,
|
||||
@@ -69,7 +71,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 5,
|
||||
gridOrder: 6,
|
||||
icon: markRaw(NotebookPen),
|
||||
iconClass: '',
|
||||
iconImage: notesIcon,
|
||||
@@ -116,12 +118,25 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
labelKey: 'Apps.clock.name',
|
||||
route: '/apps/clock',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/WeatherApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 4,
|
||||
icon: markRaw(CloudSun),
|
||||
iconClass: 'app-icon--weather',
|
||||
iconImage: weatherIcon,
|
||||
id: 'weather',
|
||||
labelKey: 'Apps.weather.name',
|
||||
route: '/apps/weather',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 6,
|
||||
gridOrder: 7,
|
||||
icon: markRaw(Images),
|
||||
iconClass: 'app-icon--photos',
|
||||
iconImage: photosIcon,
|
||||
@@ -134,7 +149,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 7,
|
||||
gridOrder: 8,
|
||||
icon: markRaw(ShoppingBag),
|
||||
iconClass: 'app-icon--store',
|
||||
iconImage: appStoreIcon,
|
||||
@@ -147,7 +162,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 8,
|
||||
gridOrder: 9,
|
||||
icon: markRaw(Settings),
|
||||
iconClass: 'app-icon--settings',
|
||||
iconImage: settingsIcon,
|
||||
|
||||
@@ -141,6 +141,47 @@ const defaultLocales: LocaleTree = {
|
||||
roads: 'Road Map',
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
name: 'Weather',
|
||||
now: 'Now',
|
||||
today: 'Today',
|
||||
refresh: 'Refresh weather',
|
||||
details: 'Weather details',
|
||||
feelsLike: 'Feels Like',
|
||||
wind: 'Wind',
|
||||
humidity: 'Humidity',
|
||||
rain: 'Precipitation',
|
||||
hourly: '24-Hour Forecast',
|
||||
daily: '7-Day Forecast',
|
||||
unavailable: 'Weather is unavailable',
|
||||
stale: 'Showing the last available weather update.',
|
||||
tryAgain: 'Try Again',
|
||||
regions: {
|
||||
los_santos: 'Los Santos',
|
||||
blaine_county: 'Blaine County',
|
||||
cayo_perico: 'Cayo Perico',
|
||||
},
|
||||
conditions: {
|
||||
sunny: 'Sunny',
|
||||
clear: 'Clear',
|
||||
partly_cloudy: 'Partly Cloudy',
|
||||
cloudy: 'Cloudy',
|
||||
rain: 'Rain',
|
||||
thunder: 'Thunderstorms',
|
||||
fog: 'Foggy',
|
||||
snow: 'Snow',
|
||||
},
|
||||
summaries: {
|
||||
sunny: 'Bright skies throughout the current period.',
|
||||
clear: 'Calm and clear conditions across the region.',
|
||||
partly_cloudy: 'Sunshine mixed with passing clouds.',
|
||||
cloudy: 'Cloud cover will remain over the region.',
|
||||
rain: 'Wet conditions are moving through the area.',
|
||||
thunder: 'Stormy conditions with heavy bursts of rain.',
|
||||
fog: 'Reduced visibility in low-lying areas.',
|
||||
snow: 'Cold conditions with snow across the region.',
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
name: 'Camera',
|
||||
shutter: 'Take photo',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import type { RawWeatherSnapshot } from '@/types/weather'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const rawWeather: RawWeatherSnapshot = {
|
||||
clock: { day: 5, hour: 17, minute: 20, month: 8, year: 2026 },
|
||||
condition: 'sunny',
|
||||
rainLevel: 0,
|
||||
region: 'los_santos',
|
||||
windSpeed: 2,
|
||||
}
|
||||
|
||||
describe('weather store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('loads and normalizes the current GTA weather', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValueOnce({
|
||||
data: rawWeather,
|
||||
success: true,
|
||||
})
|
||||
const weather = useWeatherStore()
|
||||
|
||||
await weather.refresh(true)
|
||||
|
||||
expect(nuiCall).toHaveBeenCalledWith('weather:get')
|
||||
expect(weather.forecast?.condition).toBe('sunny')
|
||||
expect(weather.forecast?.hourly).toHaveLength(24)
|
||||
expect(weather.error).toBeNull()
|
||||
})
|
||||
|
||||
it('throttles non-forced refreshes', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValue({ data: rawWeather, success: true })
|
||||
const weather = useWeatherStore()
|
||||
|
||||
await weather.refresh()
|
||||
await weather.refresh()
|
||||
|
||||
expect(nuiCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the last valid forecast when a refresh fails', async () => {
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({ data: rawWeather, success: true })
|
||||
.mockResolvedValueOnce({ error: 'offline', success: false })
|
||||
const weather = useWeatherStore()
|
||||
|
||||
await weather.refresh(true)
|
||||
const previous = weather.forecast
|
||||
await weather.refresh(true)
|
||||
|
||||
expect(weather.forecast).toBe(previous)
|
||||
expect(weather.error).toBe('offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { RawWeatherSnapshot, WeatherForecast } from '@/types/weather'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { buildWeatherForecast } from '@/utils/weather'
|
||||
|
||||
const REFRESH_INTERVAL = 30_000
|
||||
|
||||
export const useWeatherStore = defineStore('weather', {
|
||||
state: () => ({
|
||||
error: null as string | null,
|
||||
forecast: null as WeatherForecast | null,
|
||||
intervalId: undefined as number | undefined,
|
||||
isLoading: false,
|
||||
lastFetchedAt: 0,
|
||||
}),
|
||||
actions: {
|
||||
async refresh(force = false): Promise<void> {
|
||||
if (this.isLoading) return
|
||||
if (!force && Date.now() - this.lastFetchedAt < REFRESH_INTERVAL) return
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<RawWeatherSnapshot>('weather:get')
|
||||
this.isLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return
|
||||
}
|
||||
this.forecast = buildWeatherForecast(response.data)
|
||||
this.lastFetchedAt = Date.now()
|
||||
this.error = null
|
||||
},
|
||||
start(): void {
|
||||
void this.refresh(true)
|
||||
if (this.intervalId !== undefined) return
|
||||
this.intervalId = window.setInterval(() => void this.refresh(true), REFRESH_INTERVAL)
|
||||
},
|
||||
stop(): void {
|
||||
if (this.intervalId !== undefined) window.clearInterval(this.intervalId)
|
||||
this.intervalId = undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,6 +5,7 @@ export type PhoneAppId =
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'weather'
|
||||
| 'mail'
|
||||
| 'map'
|
||||
| 'notes'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export type WeatherConditionId =
|
||||
| 'sunny'
|
||||
| 'clear'
|
||||
| 'partly_cloudy'
|
||||
| 'cloudy'
|
||||
| 'rain'
|
||||
| 'thunder'
|
||||
| 'fog'
|
||||
| 'snow'
|
||||
|
||||
export type WeatherRegionId =
|
||||
| 'los_santos'
|
||||
| 'blaine_county'
|
||||
| 'cayo_perico'
|
||||
|
||||
export type WeatherClock = {
|
||||
day: number
|
||||
hour: number
|
||||
minute: number
|
||||
month: number
|
||||
year: number
|
||||
}
|
||||
|
||||
export type RawWeatherSnapshot = {
|
||||
clock: WeatherClock
|
||||
condition: WeatherConditionId
|
||||
rainLevel: number
|
||||
region: WeatherRegionId
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
export type HourlyWeather = {
|
||||
condition: WeatherConditionId
|
||||
rainChance: number
|
||||
temperature: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type DailyWeather = {
|
||||
condition: WeatherConditionId
|
||||
high: number
|
||||
low: number
|
||||
rainChance: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type WeatherForecast = {
|
||||
condition: WeatherConditionId
|
||||
daily: DailyWeather[]
|
||||
feelsLike: number
|
||||
hourly: HourlyWeather[]
|
||||
humidity: number
|
||||
rainChance: number
|
||||
region: WeatherRegionId
|
||||
temperature: number
|
||||
timestamp: number
|
||||
windSpeed: number
|
||||
}
|
||||
@@ -52,6 +52,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
calculator: { enabled: true, sounds: true },
|
||||
camera: { enabled: true, sounds: true },
|
||||
clock: { enabled: true, sounds: true },
|
||||
weather: { enabled: true, sounds: true },
|
||||
mail: { enabled: true, sounds: true },
|
||||
map: { enabled: true, sounds: true },
|
||||
notes: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RawWeatherSnapshot, WeatherRegionId } from '@/types/weather'
|
||||
import { buildWeatherForecast } from './weather'
|
||||
|
||||
function snapshot(
|
||||
region: WeatherRegionId = 'los_santos',
|
||||
): RawWeatherSnapshot {
|
||||
return {
|
||||
clock: { day: 31, hour: 23, minute: 30, month: 12, year: 2026 },
|
||||
condition: 'partly_cloudy',
|
||||
rainLevel: 0.1,
|
||||
region,
|
||||
windSpeed: 4,
|
||||
}
|
||||
}
|
||||
|
||||
describe('weather forecast', () => {
|
||||
it('builds deterministic 24-hour and seven-day forecasts', () => {
|
||||
const first = buildWeatherForecast(snapshot())
|
||||
const second = buildWeatherForecast(snapshot())
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(first.hourly).toHaveLength(24)
|
||||
expect(first.daily).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('rolls hourly timestamps into the next year', () => {
|
||||
const forecast = buildWeatherForecast(snapshot())
|
||||
const nextHour = new Date(forecast.hourly[1].timestamp)
|
||||
|
||||
expect(nextHour.getUTCFullYear()).toBe(2027)
|
||||
expect(nextHour.getUTCMonth()).toBe(0)
|
||||
expect(nextHour.getUTCDate()).toBe(1)
|
||||
})
|
||||
|
||||
it('applies a distinct climate to every supported region', () => {
|
||||
const losSantos = buildWeatherForecast(snapshot('los_santos'))
|
||||
const blaineCounty = buildWeatherForecast(snapshot('blaine_county'))
|
||||
const cayoPerico = buildWeatherForecast(snapshot('cayo_perico'))
|
||||
|
||||
expect(cayoPerico.temperature).toBeGreaterThan(losSantos.temperature)
|
||||
expect(losSantos.temperature).toBeGreaterThan(blaineCounty.temperature)
|
||||
})
|
||||
|
||||
it('keeps native rain and wind values in the current conditions', () => {
|
||||
const input = snapshot()
|
||||
input.condition = 'rain'
|
||||
input.rainLevel = 0.95
|
||||
input.windSpeed = 5
|
||||
const forecast = buildWeatherForecast(input)
|
||||
|
||||
expect(forecast.rainChance).toBe(95)
|
||||
expect(forecast.windSpeed).toBe(18)
|
||||
expect(forecast.humidity).toBeGreaterThanOrEqual(82)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
DailyWeather,
|
||||
HourlyWeather,
|
||||
RawWeatherSnapshot,
|
||||
WeatherConditionId,
|
||||
WeatherForecast,
|
||||
WeatherRegionId,
|
||||
} from '@/types/weather'
|
||||
|
||||
const CONDITIONS: WeatherConditionId[] = [
|
||||
'sunny',
|
||||
'clear',
|
||||
'partly_cloudy',
|
||||
'cloudy',
|
||||
'rain',
|
||||
'thunder',
|
||||
'fog',
|
||||
'snow',
|
||||
]
|
||||
|
||||
const REGION_BASE_TEMPERATURE: Record<WeatherRegionId, number> = {
|
||||
los_santos: 20,
|
||||
blaine_county: 17,
|
||||
cayo_perico: 26,
|
||||
}
|
||||
|
||||
const CONDITION_TEMPERATURE_OFFSET: Record<WeatherConditionId, number> = {
|
||||
sunny: 3,
|
||||
clear: 2,
|
||||
partly_cloudy: 0,
|
||||
cloudy: -2,
|
||||
rain: -4,
|
||||
thunder: -5,
|
||||
fog: -3,
|
||||
snow: -16,
|
||||
}
|
||||
|
||||
const RAIN_CHANCE: Record<WeatherConditionId, number> = {
|
||||
sunny: 2,
|
||||
clear: 4,
|
||||
partly_cloudy: 18,
|
||||
cloudy: 34,
|
||||
rain: 82,
|
||||
thunder: 96,
|
||||
fog: 24,
|
||||
snow: 68,
|
||||
}
|
||||
|
||||
const HUMIDITY: Record<WeatherConditionId, number> = {
|
||||
sunny: 38,
|
||||
clear: 42,
|
||||
partly_cloudy: 52,
|
||||
cloudy: 64,
|
||||
rain: 82,
|
||||
thunder: 88,
|
||||
fog: 92,
|
||||
snow: 76,
|
||||
}
|
||||
|
||||
const TRANSITIONS: Record<WeatherConditionId, WeatherConditionId[]> = {
|
||||
sunny: ['sunny', 'sunny', 'clear', 'partly_cloudy'],
|
||||
clear: ['clear', 'sunny', 'partly_cloudy', 'cloudy'],
|
||||
partly_cloudy: ['partly_cloudy', 'clear', 'cloudy', 'rain'],
|
||||
cloudy: ['cloudy', 'partly_cloudy', 'rain', 'fog'],
|
||||
rain: ['rain', 'cloudy', 'partly_cloudy', 'thunder'],
|
||||
thunder: ['rain', 'cloudy', 'thunder', 'partly_cloudy'],
|
||||
fog: ['fog', 'cloudy', 'partly_cloudy', 'clear'],
|
||||
snow: ['snow', 'cloudy', 'snow', 'partly_cloudy'],
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
}
|
||||
|
||||
function seedFrom(value: string): number {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
function random(seed: number): number {
|
||||
let value = seed + 0x6d2b79f5
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1)
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
function normalizedCondition(value: string): WeatherConditionId {
|
||||
return CONDITIONS.includes(value as WeatherConditionId)
|
||||
? (value as WeatherConditionId)
|
||||
: 'clear'
|
||||
}
|
||||
|
||||
function temperatureAt(
|
||||
region: WeatherRegionId,
|
||||
condition: WeatherConditionId,
|
||||
hour: number,
|
||||
variation: number,
|
||||
): number {
|
||||
const dayCurve = Math.cos(((hour - 14) / 24) * Math.PI * 2) * 5.5
|
||||
return Math.round(
|
||||
REGION_BASE_TEMPERATURE[region] +
|
||||
CONDITION_TEMPERATURE_OFFSET[condition] +
|
||||
dayCurve +
|
||||
variation,
|
||||
)
|
||||
}
|
||||
|
||||
function nextCondition(
|
||||
condition: WeatherConditionId,
|
||||
seed: number,
|
||||
): WeatherConditionId {
|
||||
const choices = TRANSITIONS[condition]
|
||||
return choices[Math.floor(random(seed) * choices.length)] ?? condition
|
||||
}
|
||||
|
||||
function snapshotTimestamp(snapshot: RawWeatherSnapshot): number {
|
||||
const { year, month, day, hour, minute } = snapshot.clock
|
||||
return Date.UTC(year, month - 1, day, hour, minute)
|
||||
}
|
||||
|
||||
export function buildWeatherForecast(
|
||||
input: RawWeatherSnapshot,
|
||||
): WeatherForecast {
|
||||
const condition = normalizedCondition(input.condition)
|
||||
const region = input.region in REGION_BASE_TEMPERATURE
|
||||
? input.region
|
||||
: 'los_santos'
|
||||
const timestamp = snapshotTimestamp(input)
|
||||
const hourSeed = seedFrom(`${region}:${timestamp}:${condition}`)
|
||||
const hourly: HourlyWeather[] = []
|
||||
let forecastCondition = condition
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
if (index > 0 && index % 3 === 0) {
|
||||
forecastCondition = nextCondition(forecastCondition, hourSeed + index)
|
||||
}
|
||||
const itemTimestamp = timestamp + index * 3_600_000
|
||||
const itemHour = new Date(itemTimestamp).getUTCHours()
|
||||
hourly.push({
|
||||
condition: forecastCondition,
|
||||
rainChance: clamp(
|
||||
RAIN_CHANCE[forecastCondition] + Math.round(random(hourSeed + index) * 12 - 6),
|
||||
0,
|
||||
100,
|
||||
),
|
||||
temperature: temperatureAt(
|
||||
region,
|
||||
forecastCondition,
|
||||
itemHour,
|
||||
random(hourSeed + index + 100) * 3 - 1.5,
|
||||
),
|
||||
timestamp: itemTimestamp,
|
||||
})
|
||||
}
|
||||
|
||||
const daily: DailyWeather[] = []
|
||||
let dailyCondition = condition
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
if (index > 0) dailyCondition = nextCondition(dailyCondition, hourSeed + index * 97)
|
||||
const itemTimestamp = timestamp + index * 86_400_000
|
||||
const variation = random(hourSeed + index * 31) * 4 - 2
|
||||
daily.push({
|
||||
condition: dailyCondition,
|
||||
high: temperatureAt(region, dailyCondition, 14, variation),
|
||||
low: temperatureAt(region, dailyCondition, 4, variation - 1),
|
||||
rainChance: clamp(
|
||||
RAIN_CHANCE[dailyCondition] + Math.round(random(hourSeed + index * 43) * 10 - 5),
|
||||
0,
|
||||
100,
|
||||
),
|
||||
timestamp: itemTimestamp,
|
||||
})
|
||||
}
|
||||
|
||||
const currentVariation = random(hourSeed) * 2 - 1
|
||||
const temperature = temperatureAt(
|
||||
region,
|
||||
condition,
|
||||
input.clock.hour + input.clock.minute / 60,
|
||||
currentVariation,
|
||||
)
|
||||
const windSpeed = Math.round(Math.max(0, input.windSpeed) * 3.6)
|
||||
const nativeRainChance = clamp(Math.round(input.rainLevel * 100), 0, 100)
|
||||
const rainChance = Math.max(RAIN_CHANCE[condition], nativeRainChance)
|
||||
|
||||
return {
|
||||
condition,
|
||||
daily,
|
||||
feelsLike: temperature - Math.round(windSpeed / 18) - (rainChance > 60 ? 1 : 0),
|
||||
hourly,
|
||||
humidity: clamp(HUMIDITY[condition] + Math.round(input.rainLevel * 8), 0, 100),
|
||||
rainChance,
|
||||
region,
|
||||
temperature,
|
||||
timestamp,
|
||||
windSpeed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
import { kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
|
||||
import {
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Droplets,
|
||||
Gauge,
|
||||
MoonStar,
|
||||
Navigation,
|
||||
RefreshCw,
|
||||
Snowflake,
|
||||
Sun,
|
||||
ThermometerSun,
|
||||
Umbrella,
|
||||
Wind,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, type Component } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import type { DailyWeather, WeatherConditionId } from '@/types/weather'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const weather = useWeatherStore()
|
||||
const forecast = computed(() => weather.forecast)
|
||||
const cardColors = {
|
||||
bgIos: 'bg-transparent',
|
||||
textIos: 'text-white',
|
||||
}
|
||||
|
||||
const conditionIcons: Record<WeatherConditionId, Component> = {
|
||||
sunny: Sun,
|
||||
clear: MoonStar,
|
||||
partly_cloudy: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rain: CloudRain,
|
||||
thunder: CloudLightning,
|
||||
fog: CloudFog,
|
||||
snow: Snowflake,
|
||||
}
|
||||
|
||||
function conditionIcon(condition: WeatherConditionId, timestamp: number): Component {
|
||||
if (condition !== 'clear') return conditionIcons[condition]
|
||||
const hour = new Date(timestamp).getUTCHours()
|
||||
return hour >= 7 && hour < 20 ? Sun : MoonStar
|
||||
}
|
||||
|
||||
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: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(timestamp)
|
||||
}
|
||||
|
||||
function formatDay(day: DailyWeather, index: number): string {
|
||||
if (index === 0) return phone.t('Apps.weather.today')
|
||||
return new Intl.DateTimeFormat(phone.lang, {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'short',
|
||||
}).format(day.timestamp)
|
||||
}
|
||||
|
||||
function temperatureRange(day: DailyWeather): Record<string, string> {
|
||||
const lows = forecast.value?.daily.map((item) => item.low) ?? [day.low]
|
||||
const highs = forecast.value?.daily.map((item) => item.high) ?? [day.high]
|
||||
const minimum = Math.min(...lows)
|
||||
const maximum = Math.max(...highs)
|
||||
const span = Math.max(1, maximum - minimum)
|
||||
return {
|
||||
'--range-left': `${((day.low - minimum) / span) * 58}%`,
|
||||
'--range-width': `${Math.max(18, ((day.high - day.low) / span) * 58)}%`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page
|
||||
component="main"
|
||||
class="weather-app"
|
||||
:class="forecast ? `weather-app--${forecast.condition}` : ''"
|
||||
:colors="{ bgIos: 'bg-transparent' }"
|
||||
>
|
||||
<div class="weather-app__backdrop" aria-hidden="true"></div>
|
||||
<k-navbar class="weather-navbar" :title="phone.t('Apps.weather.name')">
|
||||
<template #after>
|
||||
<k-link
|
||||
component="button"
|
||||
:aria-label="phone.t('Apps.weather.refresh')"
|
||||
:disabled="weather.isLoading"
|
||||
@click="weather.refresh(true)"
|
||||
>
|
||||
<RefreshCw :size="17" :class="{ 'weather-spin': weather.isLoading }" />
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div v-if="forecast" class="weather-scroll">
|
||||
<header class="weather-hero">
|
||||
<div class="weather-location">
|
||||
<Navigation :size="13" fill="currentColor" />
|
||||
{{ phone.t(`Apps.weather.regions.${forecast.region}`) }}
|
||||
</div>
|
||||
<component
|
||||
:is="conditionIcon(forecast.condition, forecast.timestamp)"
|
||||
class="weather-hero__icon"
|
||||
:size="82"
|
||||
:stroke-width="1.25"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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" class="weather-stale">
|
||||
{{ phone.t('Apps.weather.stale') }}
|
||||
</p>
|
||||
|
||||
<section class="weather-details" :aria-label="phone.t('Apps.weather.details')">
|
||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
||||
<ThermometerSun :size="18" />
|
||||
<span>{{ phone.t('Apps.weather.feelsLike') }}</span>
|
||||
<strong>{{ forecast.feelsLike }}°</strong>
|
||||
</k-card>
|
||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
||||
<Wind :size="18" />
|
||||
<span>{{ phone.t('Apps.weather.wind') }}</span>
|
||||
<strong>{{ forecast.windSpeed }} km/h</strong>
|
||||
</k-card>
|
||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
||||
<Droplets :size="18" />
|
||||
<span>{{ phone.t('Apps.weather.humidity') }}</span>
|
||||
<strong>{{ forecast.humidity }}%</strong>
|
||||
</k-card>
|
||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
||||
<Umbrella :size="18" />
|
||||
<span>{{ phone.t('Apps.weather.rain') }}</span>
|
||||
<strong>{{ forecast.rainChance }}%</strong>
|
||||
</k-card>
|
||||
</section>
|
||||
|
||||
<k-card
|
||||
:colors="cardColors"
|
||||
: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>
|
||||
<component
|
||||
:is="conditionIcon(hour.condition, hour.timestamp)"
|
||||
:size="24"
|
||||
:stroke-width="1.7"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<small v-if="hour.rainChance >= 30">{{ hour.rainChance }}%</small>
|
||||
<strong>{{ hour.temperature }}°</strong>
|
||||
</div>
|
||||
</div>
|
||||
</k-card>
|
||||
|
||||
<k-card
|
||||
:colors="cardColors"
|
||||
:content-wrap="false"
|
||||
class="weather-panel weather-daily-panel"
|
||||
>
|
||||
<h2><CloudSun :size="15" />{{ phone.t('Apps.weather.daily') }}</h2>
|
||||
<div
|
||||
v-for="(day, index) in forecast.daily"
|
||||
:key="day.timestamp"
|
||||
class="weather-day"
|
||||
>
|
||||
<strong>{{ formatDay(day, index) }}</strong>
|
||||
<span class="weather-day__condition">
|
||||
<component
|
||||
:is="conditionIcon(day.condition, day.timestamp)"
|
||||
:size="24"
|
||||
:stroke-width="1.7"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<small v-if="day.rainChance >= 30">{{ day.rainChance }}%</small>
|
||||
</span>
|
||||
<span class="weather-day__low">{{ day.low }}°</span>
|
||||
<span class="weather-day__range" :style="temperatureRange(day)">
|
||||
<i></i>
|
||||
</span>
|
||||
<span>{{ day.high }}°</span>
|
||||
</div>
|
||||
</k-card>
|
||||
</div>
|
||||
|
||||
<div v-else class="weather-empty">
|
||||
<k-preloader v-if="weather.isLoading" />
|
||||
<CloudSun v-else :size="52" :stroke-width="1.4" />
|
||||
<strong>{{ phone.t(weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable') }}</strong>
|
||||
<k-link v-if="!weather.isLoading" component="button" @click="weather.refresh(true)">
|
||||
{{ phone.t('Apps.weather.tryAgain') }}
|
||||
</k-link>
|
||||
</div>
|
||||
</k-page>
|
||||
</template>
|
||||
@@ -94,6 +94,19 @@ function counts() {
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
if (endpoint === 'weather:get') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
|
||||
condition: 'partly_cloudy',
|
||||
rainLevel: 0.08,
|
||||
region: 'los_santos',
|
||||
windSpeed: 3.2,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'map:getPlayerCoords') {
|
||||
response.json({
|
||||
success: true,
|
||||
|
||||
@@ -117,6 +117,23 @@ Locales["en"] = {
|
||||
imageError = "The map image could not be loaded.", switchStyle = "Switch Map Type",
|
||||
styles = { default = "Default Map", satellite = "Satellite Map", atlas = "Atlas Map", roads = "Road Map" },
|
||||
},
|
||||
weather = {
|
||||
name = "Weather", now = "Now", today = "Today", refresh = "Refresh weather", details = "Weather details",
|
||||
feelsLike = "Feels Like", wind = "Wind", humidity = "Humidity", rain = "Precipitation",
|
||||
hourly = "24-Hour Forecast", daily = "7-Day Forecast", unavailable = "Weather is unavailable",
|
||||
stale = "Showing the last available weather update.", tryAgain = "Try Again",
|
||||
regions = { los_santos = "Los Santos", blaine_county = "Blaine County", cayo_perico = "Cayo Perico" },
|
||||
conditions = {
|
||||
sunny = "Sunny", clear = "Clear", partly_cloudy = "Partly Cloudy", cloudy = "Cloudy",
|
||||
rain = "Rain", thunder = "Thunderstorms", fog = "Foggy", snow = "Snow",
|
||||
},
|
||||
summaries = {
|
||||
sunny = "Bright skies throughout the current period.", clear = "Calm and clear conditions across the region.",
|
||||
partly_cloudy = "Sunshine mixed with passing clouds.", cloudy = "Cloud cover will remain over the region.",
|
||||
rain = "Wet conditions are moving through the area.", thunder = "Stormy conditions with heavy bursts of rain.",
|
||||
fog = "Reduced visibility in low-lying areas.", snow = "Cold conditions with snow across the region.",
|
||||
},
|
||||
},
|
||||
notes = {
|
||||
name = "Notes", note = "Note", back = "Back", actions = "Note actions",
|
||||
newNote = "New Note", searchPlaceholder = "Search Notes",
|
||||
|
||||
@@ -158,6 +158,55 @@ RegisterNUICallback("map:getPlayerCoords", function(_, cb)
|
||||
})
|
||||
end)
|
||||
|
||||
local weather_types = {
|
||||
[joaat("EXTRASUNNY")] = "sunny",
|
||||
[joaat("CLEAR")] = "clear",
|
||||
[joaat("CLOUDS")] = "partly_cloudy",
|
||||
[joaat("OVERCAST")] = "cloudy",
|
||||
[joaat("RAIN")] = "rain",
|
||||
[joaat("CLEARING")] = "rain",
|
||||
[joaat("THUNDER")] = "thunder",
|
||||
[joaat("SMOG")] = "fog",
|
||||
[joaat("FOGGY")] = "fog",
|
||||
[joaat("NEUTRAL")] = "cloudy",
|
||||
[joaat("SNOW")] = "snow",
|
||||
[joaat("BLIZZARD")] = "snow",
|
||||
[joaat("SNOWLIGHT")] = "snow",
|
||||
[joaat("XMAS")] = "snow",
|
||||
[joaat("HALLOWEEN")] = "cloudy",
|
||||
}
|
||||
|
||||
local function weather_region(coords)
|
||||
if coords.x > 2500.0 and coords.y < -3000.0 then
|
||||
return "cayo_perico"
|
||||
end
|
||||
if coords.y > 900.0 then
|
||||
return "blaine_county"
|
||||
end
|
||||
return "los_santos"
|
||||
end
|
||||
|
||||
RegisterNUICallback("weather:get", function(_, cb)
|
||||
local coords = GetEntityCoords(PlayerPedId())
|
||||
local weather_hash = GetPrevWeatherTypeHashName()
|
||||
cb({
|
||||
success = true,
|
||||
data = {
|
||||
condition = weather_types[weather_hash] or "clear",
|
||||
region = weather_region(coords),
|
||||
clock = {
|
||||
year = GetClockYear(),
|
||||
month = GetClockMonth() + 1,
|
||||
day = GetClockDayOfMonth(),
|
||||
hour = GetClockHours(),
|
||||
minute = GetClockMinutes(),
|
||||
},
|
||||
windSpeed = math.max(0.0, GetWindSpeed()),
|
||||
rainLevel = math.max(0.0, math.min(1.0, GetRainLevel())),
|
||||
},
|
||||
})
|
||||
end)
|
||||
|
||||
for _, callback_name in ipairs(server_callbacks) do
|
||||
RegisterNUICallback(callback_name, function(data, cb)
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
|
||||
|
||||
Reference in New Issue
Block a user