mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-06 05:25:41 -05:00
* feat: add sun and moon view to WeatherTab * feat: hourly forecast and scrollable date * fix: put listviews in loaders to prevent ui blocking * dankdash/weather: wrap all tab content in loaders, weather updates - remove a bunch of transitions that make things feel glitchy - use animation durations from Theme - configurable detailed/compact hourly view * weather: fix scroll and some display issues --------- Co-authored-by: bbedward <bbedward@gmail.com>
975 lines
32 KiB
QML
975 lines
32 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Common
|
|
import "../Common/suncalc.js" as SunCalc
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property int refCount: 0
|
|
|
|
property var selectedDate: new Date()
|
|
property var weather: ({
|
|
"available": false,
|
|
"loading": true,
|
|
"temp": 0,
|
|
"tempF": 0,
|
|
"feelsLike": 0,
|
|
"feelsLikeF": 0,
|
|
"city": "",
|
|
"country": "",
|
|
"wCode": 0,
|
|
"humidity": 0,
|
|
"wind": "",
|
|
"sunrise": "06:00",
|
|
"sunset": "18:00",
|
|
"uv": 0,
|
|
"pressure": 0,
|
|
"precipitationProbability": 0,
|
|
"isDay": true,
|
|
"forecast": []
|
|
})
|
|
|
|
property var location: null
|
|
property int updateInterval: 900000 // 15 minutes
|
|
property int retryAttempts: 0
|
|
property int maxRetryAttempts: 3
|
|
property int retryDelay: 30000
|
|
property int lastFetchTime: 0
|
|
property int minFetchInterval: 30000
|
|
property int persistentRetryCount: 0
|
|
|
|
readonly property var lowPriorityCmd: ["nice", "-n", "19", "ionice", "-c3"]
|
|
readonly property var curlBaseCmd: ["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "100k", "--compressed"]
|
|
|
|
property var weatherIcons: ({
|
|
"0": "clear_day",
|
|
"1": "clear_day",
|
|
"2": "partly_cloudy_day",
|
|
"3": "cloud",
|
|
"45": "foggy",
|
|
"48": "foggy",
|
|
"51": "rainy",
|
|
"53": "rainy",
|
|
"55": "rainy",
|
|
"56": "rainy",
|
|
"57": "rainy",
|
|
"61": "rainy",
|
|
"63": "rainy",
|
|
"65": "rainy",
|
|
"66": "rainy",
|
|
"67": "rainy",
|
|
"71": "cloudy_snowing",
|
|
"73": "cloudy_snowing",
|
|
"75": "snowing_heavy",
|
|
"77": "cloudy_snowing",
|
|
"80": "rainy",
|
|
"81": "rainy",
|
|
"82": "rainy",
|
|
"85": "cloudy_snowing",
|
|
"86": "snowing_heavy",
|
|
"95": "thunderstorm",
|
|
"96": "thunderstorm",
|
|
"99": "thunderstorm"
|
|
})
|
|
|
|
property var nightWeatherIcons: ({
|
|
"0": "clear_night",
|
|
"1": "clear_night",
|
|
"2": "partly_cloudy_night",
|
|
"3": "cloud",
|
|
"45": "foggy",
|
|
"48": "foggy",
|
|
"51": "rainy",
|
|
"53": "rainy",
|
|
"55": "rainy",
|
|
"56": "rainy",
|
|
"57": "rainy",
|
|
"61": "rainy",
|
|
"63": "rainy",
|
|
"65": "rainy",
|
|
"66": "rainy",
|
|
"67": "rainy",
|
|
"71": "cloudy_snowing",
|
|
"73": "cloudy_snowing",
|
|
"75": "snowing_heavy",
|
|
"77": "cloudy_snowing",
|
|
"80": "rainy",
|
|
"81": "rainy",
|
|
"82": "rainy",
|
|
"85": "cloudy_snowing",
|
|
"86": "snowing_heavy",
|
|
"95": "thunderstorm",
|
|
"96": "thunderstorm",
|
|
"99": "thunderstorm"
|
|
})
|
|
|
|
function getWeatherIcon(code, isDay) {
|
|
if (typeof isDay === "undefined") {
|
|
isDay = weather.isDay;
|
|
}
|
|
const iconMap = isDay ? weatherIcons : nightWeatherIcons;
|
|
return iconMap[String(code)] || "cloud";
|
|
}
|
|
|
|
function getWeatherCondition(code) {
|
|
const conditions = {
|
|
"0": "Clear",
|
|
"1": "Clear",
|
|
"2": "Partly cloudy",
|
|
"3": "Overcast",
|
|
"45": "Fog",
|
|
"48": "Fog",
|
|
"51": "Drizzle",
|
|
"53": "Drizzle",
|
|
"55": "Drizzle",
|
|
"56": "Freezing drizzle",
|
|
"57": "Freezing drizzle",
|
|
"61": "Light rain",
|
|
"63": "Rain",
|
|
"65": "Heavy rain",
|
|
"66": "Light rain",
|
|
"67": "Heavy rain",
|
|
"71": "Light snow",
|
|
"73": "Snow",
|
|
"75": "Heavy snow",
|
|
"77": "Snow",
|
|
"80": "Light rain",
|
|
"81": "Rain",
|
|
"82": "Heavy rain",
|
|
"85": "Light snow showers",
|
|
"86": "Heavy snow showers",
|
|
"95": "Thunderstorm",
|
|
"96": "Thunderstorm with hail",
|
|
"99": "Thunderstorm with hail"
|
|
};
|
|
return conditions[String(code)] || "Unknown";
|
|
}
|
|
|
|
property var moonWeatherIcons: ({
|
|
"0": "",
|
|
"1": "",
|
|
"2": "",
|
|
"3": "",
|
|
"4": "",
|
|
"5": "",
|
|
"6": "",
|
|
"7": "",
|
|
"8": "",
|
|
"9": "",
|
|
"10": "",
|
|
"11": "",
|
|
"12": "",
|
|
"13": "",
|
|
"14": "",
|
|
"15": "",
|
|
"16": "",
|
|
"17": "",
|
|
"18": "",
|
|
"19": "",
|
|
"20": "",
|
|
"21": "",
|
|
"22": "",
|
|
"23": "",
|
|
"24": "",
|
|
"25": "",
|
|
"26": "",
|
|
"27": ""
|
|
})
|
|
|
|
property var moonMaterialIcons: ({
|
|
"0": "",
|
|
"1": "",
|
|
"2": "",
|
|
"3": "",
|
|
"4": "",
|
|
"5": "",
|
|
"6": "",
|
|
"7": ""
|
|
})
|
|
|
|
function getMoonPhase(date) {
|
|
const icons = moonWeatherIcons; // more icons in this set but thinner outline than material icons
|
|
// const icons = moonMaterialIcons
|
|
const iconCount = Object.keys(icons).length;
|
|
const moon = SunCalc.getMoonIllumination(date);
|
|
const index = ((Math.floor(moon.phase * iconCount + 0.5) % iconCount) + iconCount) % iconCount;
|
|
|
|
return icons[index];
|
|
}
|
|
|
|
function getMoonAngle(date) {
|
|
if (!location) {
|
|
return;
|
|
}
|
|
const pos = SunCalc.getMoonPosition(date, location.latitude, location.longitude);
|
|
return pos.parralacticAngle;
|
|
}
|
|
|
|
function getLocation() {
|
|
return location;
|
|
}
|
|
|
|
function getSunDeclination(date) {
|
|
return SunCalc.sunCoords(SunCalc.toDays(date)).dec * 180 / Math.PI;
|
|
}
|
|
|
|
function getSunTimes(date) {
|
|
if (!location) {
|
|
return null;
|
|
}
|
|
return SunCalc.getTimes(date, location.latitude, location.longitude, location.elevation);
|
|
}
|
|
|
|
function getEcliptic(date, points = 60) {
|
|
if (!location) {
|
|
return null;
|
|
}
|
|
const lat = location.latitude;
|
|
const lon = location.longitude;
|
|
const times = SunCalc.getTimes(date, lat, lon);
|
|
const solarNoon = times.solarNoon;
|
|
|
|
const eclipticPoints = [];
|
|
|
|
const sunIsNorth = getSunDeclination(date) > lat;
|
|
const transitAzimuth = sunIsNorth ? 0 : Math.PI;
|
|
|
|
for (let i = 0; i <= points; i++) {
|
|
const t = new Date(solarNoon.getTime() + (i / points) * 24 * 60 * 60 * 1000);
|
|
const pos = SunCalc.getPosition(t, lat, lon);
|
|
|
|
let h = (((pos.azimuth - transitAzimuth) / (2 * Math.PI)) + 1) % 1;
|
|
h = Math.max(0, Math.min(1, h));
|
|
let v = Math.sin(pos.altitude);
|
|
v = Math.max(-1, Math.min(1, v));
|
|
|
|
eclipticPoints.push({
|
|
h,
|
|
v
|
|
});
|
|
}
|
|
|
|
const sortedEntries = eclipticPoints.sort((a, b) => a.h - b.h);
|
|
return sortedEntries;
|
|
}
|
|
|
|
function getCurrentSunTime(date) {
|
|
const times = getSunTimes(date);
|
|
if (!times) {
|
|
return;
|
|
}
|
|
const dateObj = new Date(date);
|
|
|
|
const periods = [
|
|
{
|
|
name: I18n.tr("Dawn (Astronomical Twilight)"),
|
|
start: new Date(times.nightEnd),
|
|
end: new Date(times.nauticalDawn)
|
|
},
|
|
{
|
|
name: I18n.tr("Dawn (Nautical Twilight)"),
|
|
start: new Date(times.nauticalDawn),
|
|
end: new Date(times.dawn)
|
|
},
|
|
{
|
|
name: I18n.tr("Dawn (Civil Twilight)"),
|
|
start: new Date(times.dawn),
|
|
end: new Date(times.sunrise)
|
|
},
|
|
{
|
|
name: I18n.tr("Sunrise"),
|
|
start: new Date(times.sunrise),
|
|
end: new Date(times.sunriseEnd)
|
|
},
|
|
{
|
|
name: I18n.tr("Golden Hour"),
|
|
start: new Date(times.sunriseEnd),
|
|
end: new Date(times.goldenHourEnd)
|
|
},
|
|
{
|
|
name: I18n.tr("Morning"),
|
|
start: new Date(times.goldenHourEnd),
|
|
end: new Date(times.solarNoon)
|
|
},
|
|
{
|
|
name: I18n.tr("Afternoon"),
|
|
start: new Date(times.solarNoon),
|
|
end: new Date(times.goldenHour)
|
|
},
|
|
{
|
|
name: I18n.tr("Golden Hour"),
|
|
start: new Date(times.goldenHour),
|
|
end: new Date(times.sunsetStart)
|
|
},
|
|
{
|
|
name: I18n.tr("Sunset"),
|
|
start: new Date(times.sunsetStart),
|
|
end: new Date(times.sunset)
|
|
},
|
|
{
|
|
name: I18n.tr("Dusk (Civil Twighlight)"),
|
|
start: new Date(times.sunset),
|
|
end: new Date(times.dusk)
|
|
},
|
|
{
|
|
name: I18n.tr("Dusk (Nautical Twilight)"),
|
|
start: new Date(times.dusk),
|
|
end: new Date(times.nauticalDusk)
|
|
},
|
|
{
|
|
name: I18n.tr("Dusk (Astronomical Twilight)"),
|
|
start: new Date(times.nauticalDusk),
|
|
end: new Date(times.night)
|
|
},
|
|
];
|
|
|
|
const sunrise = new Date(times.nightEnd);
|
|
const sunset = new Date(times.night);
|
|
const dayPercent = dateObj > sunrise && dateObj < sunset ? (dateObj - sunrise) / (sunset - sunrise) : 0;
|
|
|
|
for (let i = 0; i < periods.length; i++) {
|
|
const {
|
|
name,
|
|
start,
|
|
end
|
|
} = periods[i];
|
|
if (dateObj >= start && dateObj < end) {
|
|
const percent = (dateObj - start) / (end - start);
|
|
return {
|
|
period: name,
|
|
periodIndex: i,
|
|
periodPercent: Math.min(Math.max(percent, 0), 1),
|
|
dayPercent: dayPercent
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
period: I18n.tr("Night"),
|
|
periodIndex: 0,
|
|
periodPercent: 0,
|
|
dayPercent: dayPercent
|
|
};
|
|
}
|
|
|
|
function getSkyArcPosition(date, isSun) {
|
|
if (!location) {
|
|
return null;
|
|
}
|
|
const lat = location.latitude;
|
|
const lon = location.longitude;
|
|
|
|
const pos = isSun ? SunCalc.getPosition(date, lat, lon) : SunCalc.getMoonPosition(date, lat, lon);
|
|
|
|
const sunIsNorth = getSunDeclination(date) > lat;
|
|
const transitAzimuth = sunIsNorth ? 0 : Math.PI;
|
|
|
|
let h = (((pos.azimuth - transitAzimuth) / (2 * Math.PI)) + 1) % 1;
|
|
h = Math.max(0, Math.min(1, h));
|
|
// let v = pos.altitude / (Math.PI/2)
|
|
let v = Math.sin(pos.altitude);
|
|
v = Math.max(-1, Math.min(1, v));
|
|
|
|
return {
|
|
h,
|
|
v
|
|
};
|
|
}
|
|
|
|
function formatTemp(celcius, includeUnits = true, unitsShort = true) {
|
|
if (celcius == null) {
|
|
return null;
|
|
}
|
|
const value = SettingsData.useFahrenheit ? Math.round(celcius * (9 / 5) + 32) : celcius;
|
|
const unit = unitsShort ? "°" : (SettingsData.useFahrenheit ? "°F" : "°C");
|
|
return includeUnits ? value + unit : value;
|
|
}
|
|
|
|
function formatSpeed(kmh, includeUnits = true) {
|
|
if (kmh == null) {
|
|
return null;
|
|
}
|
|
const value = SettingsData.useFahrenheit ? Math.round(kmh * 0.621371) : kmh;
|
|
const unit = SettingsData.useFahrenheit ? "mph" : "km/h";
|
|
return includeUnits ? value + " " + unit : value;
|
|
}
|
|
|
|
function formatPressure(hpa, includeUnits = true) {
|
|
if (hpa == null) {
|
|
return null;
|
|
}
|
|
const value = SettingsData.useFahrenheit ? (hpa * 0.02953).toFixed(2) : hpa;
|
|
const unit = SettingsData.useFahrenheit ? "inHg" : "hPa";
|
|
return includeUnits ? value + " " + unit : value;
|
|
}
|
|
|
|
function formatPercent(percent, includeUnits = true) {
|
|
if (percent == null) {
|
|
return null;
|
|
}
|
|
const value = percent;
|
|
const unit = "%";
|
|
return includeUnits ? value + unit : value;
|
|
}
|
|
|
|
function formatVisibility(distance) {
|
|
if (distance == null) {
|
|
return null;
|
|
}
|
|
var value;
|
|
var unit;
|
|
if (SettingsData.useFahrenheit) {
|
|
value = (distance / 1609.344).toFixed(1);
|
|
unit = "mi";
|
|
if (value < 1) {
|
|
value = Math.round(value * 5280 / 50) * 50;
|
|
unit = "ft";
|
|
}
|
|
} else {
|
|
value = distance;
|
|
unit = "m";
|
|
if (value > 1000) {
|
|
value = (value / 1000).toFixed(1);
|
|
unit = "km";
|
|
}
|
|
}
|
|
|
|
return value + " " + unit;
|
|
}
|
|
|
|
function formatTime(isoString) {
|
|
if (!isoString)
|
|
return "--";
|
|
|
|
try {
|
|
const date = new Date(isoString);
|
|
const format = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP";
|
|
return date.toLocaleTimeString(Qt.locale(), format);
|
|
} catch (e) {
|
|
return "--";
|
|
}
|
|
}
|
|
|
|
function calendarDayDifference(date1, date2) {
|
|
const d1 = Date.UTC(date1.getFullYear(), date1.getMonth(), date1.getDate());
|
|
const d2 = Date.UTC(date2.getFullYear(), date2.getMonth(), date2.getDate());
|
|
return Math.floor((d2 - d1) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function calendarHourDifference(date1, date2) {
|
|
const d1 = Date.UTC(date1.getFullYear(), date1.getMonth(), date1.getDate(), date1.getHours());
|
|
const d2 = Date.UTC(date2.getFullYear(), date2.getMonth(), date2.getDate(), date2.getHours());
|
|
return Math.floor((d2 - d1) / (1000 * 60 * 60));
|
|
}
|
|
|
|
function formatForecastDay(isoString, index) {
|
|
if (!isoString)
|
|
return "--";
|
|
|
|
if (index === 0)
|
|
return I18n.tr("Today");
|
|
if (index === 1)
|
|
return I18n.tr("Tomorrow");
|
|
|
|
const date = new Date();
|
|
date.setDate(date.getDate() + index);
|
|
const locale = Qt.locale();
|
|
return locale.dayName(date.getDay(), Locale.ShortFormat);
|
|
}
|
|
|
|
function getWeatherApiUrl() {
|
|
if (!location) {
|
|
return null;
|
|
}
|
|
|
|
const params = ["latitude=" + location.latitude, "longitude=" + location.longitude, "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,surface_pressure,wind_speed_10m", "daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max", "hourly=temperature_2m,weather_code,precipitation_probability,wind_speed_10m,apparent_temperature,relative_humidity_2m,surface_pressure,visibility,cloud_cover", "timezone=auto", "forecast_days=7"];
|
|
|
|
return "https://api.open-meteo.com/v1/forecast?" + params.join('&');
|
|
}
|
|
|
|
function getGeocodingUrl(query) {
|
|
return "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(query) + "&count=1&language=en&format=json";
|
|
}
|
|
|
|
function addRef() {
|
|
refCount++;
|
|
|
|
if (refCount === 1 && !weather.available && SettingsData.weatherEnabled) {
|
|
fetchWeather();
|
|
}
|
|
}
|
|
|
|
function removeRef() {
|
|
refCount = Math.max(0, refCount - 1);
|
|
}
|
|
|
|
function updateLocation() {
|
|
if (SettingsData.useAutoLocation) {
|
|
getLocationFromIP();
|
|
} else {
|
|
const coords = SettingsData.weatherCoordinates;
|
|
if (coords) {
|
|
const parts = coords.split(",");
|
|
if (parts.length === 2) {
|
|
const lat = parseFloat(parts[0]);
|
|
const lon = parseFloat(parts[1]);
|
|
if (!isNaN(lat) && !isNaN(lon)) {
|
|
getLocationFromCoords(lat, lon);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const cityName = SettingsData.weatherLocation;
|
|
if (cityName) {
|
|
getLocationFromCity(cityName);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getLocationFromCoords(lat, lon) {
|
|
const url = "https://nominatim.openstreetmap.org/reverse?lat=" + lat + "&lon=" + lon + "&format=json&addressdetails=1&accept-language=en";
|
|
reverseGeocodeFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat(["-H", "User-Agent: DankMaterialShell Weather Widget", url]);
|
|
reverseGeocodeFetcher.running = true;
|
|
}
|
|
|
|
function getLocationFromCity(city) {
|
|
cityGeocodeFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([getGeocodingUrl(city)]);
|
|
cityGeocodeFetcher.running = true;
|
|
}
|
|
|
|
function getLocationFromIP() {
|
|
ipLocationFetcher.running = true;
|
|
}
|
|
|
|
function fetchWeather() {
|
|
if (root.refCount === 0 || !SettingsData.weatherEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (!location) {
|
|
updateLocation();
|
|
return;
|
|
}
|
|
|
|
if (weatherFetcher.running) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - root.lastFetchTime < root.minFetchInterval) {
|
|
return;
|
|
}
|
|
|
|
const apiUrl = getWeatherApiUrl();
|
|
if (!apiUrl) {
|
|
return;
|
|
}
|
|
|
|
root.lastFetchTime = now;
|
|
root.weather.loading = true;
|
|
const weatherCmd = lowPriorityCmd.concat(["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "150k", "--compressed"]);
|
|
weatherFetcher.command = weatherCmd.concat([apiUrl]);
|
|
weatherFetcher.running = true;
|
|
}
|
|
|
|
function forceRefresh() {
|
|
root.lastFetchTime = 0; // Reset throttle
|
|
fetchWeather();
|
|
}
|
|
|
|
function nextInterval() {
|
|
const jitter = Math.floor(Math.random() * 15000) - 7500;
|
|
return Math.max(60000, root.updateInterval + jitter);
|
|
}
|
|
|
|
function handleWeatherSuccess() {
|
|
root.retryAttempts = 0;
|
|
root.persistentRetryCount = 0;
|
|
if (persistentRetryTimer.running) {
|
|
persistentRetryTimer.stop();
|
|
}
|
|
if (updateTimer.interval !== root.updateInterval) {
|
|
updateTimer.interval = root.updateInterval;
|
|
}
|
|
}
|
|
|
|
function handleWeatherFailure() {
|
|
root.retryAttempts++;
|
|
if (root.retryAttempts < root.maxRetryAttempts) {
|
|
retryTimer.start();
|
|
} else {
|
|
root.retryAttempts = 0;
|
|
if (!root.weather.available) {
|
|
root.weather.loading = false;
|
|
}
|
|
const backoffDelay = Math.min(60000 * Math.pow(2, persistentRetryCount), 300000);
|
|
persistentRetryCount++;
|
|
persistentRetryTimer.interval = backoffDelay;
|
|
persistentRetryTimer.start();
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: ipLocationFetcher
|
|
command: lowPriorityCmd.concat(curlBaseCmd).concat(["http://ip-api.com/json/"])
|
|
running: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const raw = text.trim();
|
|
if (!raw || raw[0] !== "{") {
|
|
root.handleWeatherFailure();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
|
|
if (data.status === "fail") {
|
|
throw new Error("IP location lookup failed");
|
|
}
|
|
|
|
const lat = parseFloat(data.lat);
|
|
const lon = parseFloat(data.lon);
|
|
const city = data.city;
|
|
|
|
if (!city || isNaN(lat) || isNaN(lon)) {
|
|
throw new Error("Missing or invalid location data");
|
|
}
|
|
|
|
root.location = {
|
|
city: city,
|
|
latitude: lat,
|
|
longitude: lon
|
|
};
|
|
fetchWeather();
|
|
} catch (e) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: reverseGeocodeFetcher
|
|
running: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const raw = text.trim();
|
|
if (!raw || raw[0] !== "{") {
|
|
root.handleWeatherFailure();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
const address = data.address || {};
|
|
|
|
root.location = {
|
|
city: address.hamlet || address.city || address.town || address.village || "Unknown",
|
|
country: address.country || "Unknown",
|
|
latitude: parseFloat(data.lat),
|
|
longitude: parseFloat(data.lon)
|
|
};
|
|
|
|
fetchWeather();
|
|
} catch (e) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: cityGeocodeFetcher
|
|
running: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const raw = text.trim();
|
|
if (!raw || raw[0] !== "{") {
|
|
root.handleWeatherFailure();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
const results = data.results;
|
|
|
|
if (!results || results.length === 0) {
|
|
throw new Error("No results found");
|
|
}
|
|
|
|
const result = results[0];
|
|
|
|
root.location = {
|
|
city: result.name,
|
|
country: result.country,
|
|
latitude: result.latitude,
|
|
longitude: result.longitude,
|
|
elevation: data.elevation
|
|
};
|
|
|
|
fetchWeather();
|
|
} catch (e) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: weatherFetcher
|
|
running: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const raw = text.trim();
|
|
if (!raw || raw[0] !== "{") {
|
|
root.handleWeatherFailure();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
|
|
if (!data.current || !data.daily || !data.hourly) {
|
|
throw new Error("Required weather data fields missing");
|
|
}
|
|
|
|
const hourly = data.hourly;
|
|
const hourly_forecast = [];
|
|
if (hourly.time && hourly.time.length > 0) {
|
|
for (let i = 0; i < hourly.time.length; i++) {
|
|
const tempMinC = hourly.temperature_2m_min?.[i] || 0;
|
|
const tempMaxC = hourly.temperature_2m_max?.[i] || 0;
|
|
const tempMinF = tempMinC * 9 / 5 + 32;
|
|
const tempMaxF = tempMaxC * 9 / 5 + 32;
|
|
|
|
const tempC = hourly.temperature_2m?.[i] || 0;
|
|
const tempF = tempC * 9 / 5 + 32;
|
|
const feelsLikeC = hourly.apparent_temperature?.[i] || tempC;
|
|
const feelsLikeF = feelsLikeC * 9 / 5 + 32;
|
|
|
|
const sunrise = new Date(data.daily.sunrise?.[Math.floor(i / 24)]);
|
|
const sunset = new Date(data.daily.sunset?.[Math.floor(i / 24)]);
|
|
const time = new Date(hourly.time[i]);
|
|
const isDay = sunrise < time && time < sunset;
|
|
|
|
hourly_forecast.push({
|
|
"time": formatTime(hourly.time[i]),
|
|
"temp": Math.round(tempC),
|
|
"tempF": Math.round(tempF),
|
|
"feelsLike": Math.round(feelsLikeC),
|
|
"feelsLikeF": Math.round(feelsLikeF),
|
|
"wCode": hourly.weather_code?.[i] || 0,
|
|
"humidity": Math.round(hourly.relative_humidity_2m?.[i] || 0),
|
|
"wind": Math.round(hourly.wind_speed_10m?.[i] || 0),
|
|
"pressure": Math.round(hourly.surface_pressure?.[i] || 0),
|
|
"precipitationProbability": Math.round(hourly.precipitation_probability_max?.[0] || 0),
|
|
"visibility": Math.round(hourly.visibility?.[i] || 0),
|
|
"isDay": isDay
|
|
});
|
|
}
|
|
}
|
|
|
|
const daily = data.daily;
|
|
const forecast = [];
|
|
if (daily.time && daily.time.length > 0) {
|
|
for (let i = 0; i < daily.time.length; i++) {
|
|
const tempMinC = daily.temperature_2m_min?.[i] || 0;
|
|
const tempMaxC = daily.temperature_2m_max?.[i] || 0;
|
|
const tempMinF = (tempMinC * 9 / 5 + 32);
|
|
const tempMaxF = (tempMaxC * 9 / 5 + 32);
|
|
|
|
forecast.push({
|
|
"day": formatForecastDay(daily.time[i], i),
|
|
"wCode": daily.weather_code?.[i] || 0,
|
|
"tempMin": Math.round(tempMinC),
|
|
"tempMax": Math.round(tempMaxC),
|
|
"tempMinF": Math.round(tempMinF),
|
|
"tempMaxF": Math.round(tempMaxF),
|
|
"precipitationProbability": Math.round(daily.precipitation_probability_max?.[i] || 0),
|
|
"sunrise": daily.sunrise?.[i] ? formatTime(daily.sunrise[i]) : "",
|
|
"sunset": daily.sunset?.[i] ? formatTime(daily.sunset[i]) : ""
|
|
});
|
|
}
|
|
}
|
|
|
|
const current = data.current;
|
|
const currentUnits = data.current_units || {};
|
|
|
|
const tempC = current.temperature_2m || 0;
|
|
const tempF = tempC * 9 / 5 + 32;
|
|
const feelsLikeC = current.apparent_temperature || tempC;
|
|
const feelsLikeF = feelsLikeC * 9 / 5 + 32;
|
|
|
|
root.weather = {
|
|
"available": true,
|
|
"loading": false,
|
|
"temp": Math.round(tempC),
|
|
"tempF": Math.round(tempF),
|
|
"feelsLike": Math.round(feelsLikeC),
|
|
"feelsLikeF": Math.round(feelsLikeF),
|
|
"city": root.location?.city || "Unknown",
|
|
"country": root.location?.country || "Unknown",
|
|
"wCode": current.weather_code || 0,
|
|
"humidity": Math.round(current.relative_humidity_2m || 0),
|
|
"wind": Math.round(current.wind_speed_10m || 0) + " " + (currentUnits.wind_speed_10m || 'm/s'),
|
|
"sunrise": formatTime(daily.sunrise?.[0]) || "06:00",
|
|
"sunset": formatTime(daily.sunset?.[0]) || "18:00",
|
|
"uv": 0,
|
|
"pressure": Math.round(current.surface_pressure || 0),
|
|
"precipitationProbability": Math.round(daily.precipitation_probability_max?.[0] || 0),
|
|
"isDay": Boolean(current.is_day),
|
|
"forecast": forecast,
|
|
"hourlyForecast": hourly_forecast
|
|
};
|
|
root.handleWeatherSuccess();
|
|
} catch (e) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0) {
|
|
root.handleWeatherFailure();
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: updateTimer
|
|
interval: nextInterval()
|
|
running: root.refCount > 0 && SettingsData.weatherEnabled
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: {
|
|
root.fetchWeather();
|
|
interval = nextInterval();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: retryTimer
|
|
interval: root.retryDelay
|
|
running: false
|
|
repeat: false
|
|
onTriggered: {
|
|
root.fetchWeather();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: persistentRetryTimer
|
|
interval: 60000
|
|
running: false
|
|
repeat: false
|
|
onTriggered: {
|
|
if (!root.weather.available) {
|
|
root.weather.loading = true;
|
|
}
|
|
root.fetchWeather();
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
SettingsData.weatherCoordinatesChanged.connect(() => {
|
|
root.location = null;
|
|
root.weather = {
|
|
"available": false,
|
|
"loading": true,
|
|
"temp": 0,
|
|
"tempF": 0,
|
|
"feelsLike": 0,
|
|
"feelsLikeF": 0,
|
|
"city": "",
|
|
"country": "",
|
|
"wCode": 0,
|
|
"humidity": 0,
|
|
"wind": "",
|
|
"sunrise": "06:00",
|
|
"sunset": "18:00",
|
|
"uv": 0,
|
|
"pressure": 0,
|
|
"precipitationProbability": 0,
|
|
"isDay": true,
|
|
"forecast": []
|
|
};
|
|
root.lastFetchTime = 0;
|
|
root.forceRefresh();
|
|
});
|
|
|
|
SettingsData.weatherLocationChanged.connect(() => {
|
|
root.location = null;
|
|
root.lastFetchTime = 0;
|
|
root.forceRefresh();
|
|
});
|
|
|
|
SettingsData.useAutoLocationChanged.connect(() => {
|
|
root.location = null;
|
|
root.weather = {
|
|
"available": false,
|
|
"loading": true,
|
|
"temp": 0,
|
|
"tempF": 0,
|
|
"feelsLike": 0,
|
|
"feelsLikeF": 0,
|
|
"city": "",
|
|
"country": "",
|
|
"wCode": 0,
|
|
"humidity": 0,
|
|
"wind": "",
|
|
"sunrise": "06:00",
|
|
"sunset": "18:00",
|
|
"uv": 0,
|
|
"pressure": 0,
|
|
"precipitationProbability": 0,
|
|
"isDay": true,
|
|
"forecast": []
|
|
};
|
|
root.lastFetchTime = 0;
|
|
root.forceRefresh();
|
|
});
|
|
|
|
SettingsData.weatherEnabledChanged.connect(() => {
|
|
if (SettingsData.weatherEnabled && root.refCount > 0 && !root.weather.available) {
|
|
root.forceRefresh();
|
|
} else if (!SettingsData.weatherEnabled) {
|
|
updateTimer.stop();
|
|
retryTimer.stop();
|
|
persistentRetryTimer.stop();
|
|
if (weatherFetcher.running) {
|
|
weatherFetcher.running = false;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|