ADD - weather app

This commit is contained in:
Type
2026-08-05 22:22:26 +02:00
parent b9ca1f1153
commit 5471040dd8
18 changed files with 1153 additions and 14 deletions
+5
View File
@@ -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

+312
View File
@@ -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;
}
+52 -7
View File
@@ -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">
+6 -1
View File
@@ -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))
+21 -6
View File
@@ -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,
+41
View File
@@ -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',
+62
View File
@@ -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')
})
})
+42
View File
@@ -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
},
},
})
+1
View File
@@ -5,6 +5,7 @@ export type PhoneAppId =
| 'calculator'
| 'camera'
| 'clock'
| 'weather'
| 'mail'
| 'map'
| 'notes'
+58
View File
@@ -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
}
+1
View File
@@ -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 },
+57
View File
@@ -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)
})
})
+202
View File
@@ -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,
}
}
+214
View File
@@ -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>
+13
View File
@@ -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,
+17
View File
@@ -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",
+49
View File
@@ -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)