build easy app
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Alternative VCS systems
|
||||||
|
/.jj/
|
||||||
|
/.hg/
|
||||||
|
/.pijul/
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
.cargo/
|
||||||
6724
Cargo.lock
generated
Normal file
6724
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "slint-rust-template"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slint = "1.14.1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slint-build = "1.14.1"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Attilio Greco
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
3
build.rs
Normal file
3
build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
slint_build::compile("ui/app-window.slint").expect("Slint build failed");
|
||||||
|
}
|
||||||
445
src/main.rs
Normal file
445
src/main.rs
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use chrono::Timelike;
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use reqwest;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use slint::{Color, ModelRc, SharedString, Timer, TimerMode, VecModel};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
slint::include_modules!();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// OpenWeatherMap API Responses
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenWeatherResponse {
|
||||||
|
main: MainWeather,
|
||||||
|
weather: Vec<Weather>,
|
||||||
|
wind: Wind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct MainWeather {
|
||||||
|
temp: f32,
|
||||||
|
feels_like: f32,
|
||||||
|
humidity: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Weather {
|
||||||
|
main: String,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Wind {
|
||||||
|
speed: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ForecastResponse {
|
||||||
|
list: Vec<ForecastItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ForecastItem {
|
||||||
|
dt: i64,
|
||||||
|
main: MainWeather,
|
||||||
|
weather: Vec<Weather>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Config persistence
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Config {
|
||||||
|
api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config_path() -> PathBuf {
|
||||||
|
let config_dir = PathBuf::from("/var/lib/slint-weather-demo");
|
||||||
|
|
||||||
|
// Fallback to home dir if /var/lib is not writable
|
||||||
|
if !config_dir.exists() {
|
||||||
|
if let Err(_) = fs::create_dir_all(&config_dir) {
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let fallback = PathBuf::from(home).join(".config/slint-weather-demo");
|
||||||
|
fs::create_dir_all(&fallback).ok();
|
||||||
|
return fallback.join("config.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_dir.join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_api_key() -> Option<String> {
|
||||||
|
let path = get_config_path();
|
||||||
|
if let Ok(contents) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(config) = serde_json::from_str::<Config>(&contents) {
|
||||||
|
return Some(config.api_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_api_key(key: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
let path = get_config_path();
|
||||||
|
let config = Config {
|
||||||
|
api_key: key.to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&config)?;
|
||||||
|
fs::write(&path, json)?;
|
||||||
|
eprintln!("✓ API key saved to {}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Fallback fake data
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fn fake_current_weather() -> OpenWeatherResponse {
|
||||||
|
OpenWeatherResponse {
|
||||||
|
main: MainWeather {
|
||||||
|
temp: 18.5,
|
||||||
|
feels_like: 16.8,
|
||||||
|
humidity: 72,
|
||||||
|
},
|
||||||
|
weather: vec![Weather {
|
||||||
|
main: "Clouds".to_string(),
|
||||||
|
description: "nubi sparse".to_string(),
|
||||||
|
}],
|
||||||
|
wind: Wind { speed: 3.2 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_forecast() -> ForecastResponse {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
ForecastResponse {
|
||||||
|
list: (0..6)
|
||||||
|
.map(|i| ForecastItem {
|
||||||
|
dt: now + (i * 3 * 3600),
|
||||||
|
main: MainWeather {
|
||||||
|
temp: 18.0 + (i as f32 * 0.5),
|
||||||
|
feels_like: 17.0,
|
||||||
|
humidity: 70,
|
||||||
|
},
|
||||||
|
weather: vec![Weather {
|
||||||
|
main: if i % 3 == 0 { "Clear" } else { "Clouds" }.to_string(),
|
||||||
|
description: "vario".to_string(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// API Calls
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async fn fetch_current_weather(api_key: &str) -> Result<OpenWeatherResponse, Box<dyn Error>> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.openweathermap.org/data/2.5/weather?q=Milano,IT&units=metric&lang=it&appid={}",
|
||||||
|
api_key
|
||||||
|
);
|
||||||
|
let text = reqwest::get(&url).await?.text().await?;
|
||||||
|
eprintln!("Current weather response: {}", text);
|
||||||
|
let response = serde_json::from_str::<OpenWeatherResponse>(&text)?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_forecast(api_key: &str) -> Result<ForecastResponse, Box<dyn Error>> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.openweathermap.org/data/2.5/forecast?q=Milano,IT&units=metric&lang=it&appid={}",
|
||||||
|
api_key
|
||||||
|
);
|
||||||
|
let response = reqwest::get(&url).await?.json::<ForecastResponse>().await?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Helpers
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::from_argb_u8(
|
||||||
|
255,
|
||||||
|
(a.red() as f32 * (1.0 - t) + b.red() as f32 * t) as u8,
|
||||||
|
(a.green() as f32 * (1.0 - t) + b.green() as f32 * t) as u8,
|
||||||
|
(a.blue() as f32 * (1.0 - t) + b.blue() as f32 * t) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SkyStop {
|
||||||
|
hour: f32,
|
||||||
|
top: Color,
|
||||||
|
bottom: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sky_colors(hour: f32) -> (Color, Color) {
|
||||||
|
let stops = [
|
||||||
|
SkyStop { hour: 0.0, top: Color::from_rgb_u8(10, 10, 46), bottom: Color::from_rgb_u8(22, 33, 62) },
|
||||||
|
SkyStop { hour: 5.0, top: Color::from_rgb_u8(10, 15, 50), bottom: Color::from_rgb_u8(25, 35, 65) },
|
||||||
|
SkyStop { hour: 6.5, top: Color::from_rgb_u8(255, 140, 50), bottom: Color::from_rgb_u8(255, 100, 80) },
|
||||||
|
SkyStop { hour: 8.0, top: Color::from_rgb_u8(50, 130, 240), bottom: Color::from_rgb_u8(120, 190, 240) },
|
||||||
|
SkyStop { hour: 12.0, top: Color::from_rgb_u8(30, 144, 255), bottom: Color::from_rgb_u8(135, 206, 235) },
|
||||||
|
SkyStop { hour: 17.0, top: Color::from_rgb_u8(30, 144, 255), bottom: Color::from_rgb_u8(135, 206, 235) },
|
||||||
|
SkyStop { hour: 19.0, top: Color::from_rgb_u8(255, 100, 50), bottom: Color::from_rgb_u8(140, 50, 80) },
|
||||||
|
SkyStop { hour: 20.5, top: Color::from_rgb_u8(30, 20, 60), bottom: Color::from_rgb_u8(40, 30, 70) },
|
||||||
|
SkyStop { hour: 24.0, top: Color::from_rgb_u8(10, 10, 46), bottom: Color::from_rgb_u8(22, 33, 62) },
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < stops.len() - 1 && stops[i + 1].hour <= hour {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i >= stops.len() - 1 {
|
||||||
|
return (stops.last().unwrap().top, stops.last().unwrap().bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
let a = &stops[i];
|
||||||
|
let b = &stops[i + 1];
|
||||||
|
let t = (hour - a.hour) / (b.hour - a.hour);
|
||||||
|
|
||||||
|
(lerp_color(a.top, b.top, t), lerp_color(a.bottom, b.bottom, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sun_y(hour: f32) -> f32 {
|
||||||
|
if hour < 6.0 || hour > 20.0 {
|
||||||
|
0.85
|
||||||
|
} else {
|
||||||
|
let normalized = (hour - 6.0) / 14.0;
|
||||||
|
let arc = (normalized * std::f32::consts::PI).sin();
|
||||||
|
0.7 - arc * 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weather_condition_to_type(condition: &str) -> i32 {
|
||||||
|
match condition {
|
||||||
|
"Clear" => 0,
|
||||||
|
"Clouds" => 1,
|
||||||
|
"Rain" | "Drizzle" | "Thunderstorm" => 2,
|
||||||
|
"Snow" => 2,
|
||||||
|
_ => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_weather_ui(window: &MainWindow, current: OpenWeatherResponse, forecast_data: ForecastResponse) {
|
||||||
|
window.set_current_temp(current.main.temp.round() as i32);
|
||||||
|
window.set_weather_desc(SharedString::from(
|
||||||
|
current.weather.first().map(|w| w.description.as_str()).unwrap_or("N/A")
|
||||||
|
));
|
||||||
|
window.set_humidity(SharedString::from(format!("{}%", current.main.humidity)));
|
||||||
|
window.set_wind(SharedString::from(format!("{} km/h", (current.wind.speed * 3.6).round() as i32)));
|
||||||
|
window.set_feels_like(SharedString::from(format!("{}°", current.main.feels_like.round() as i32)));
|
||||||
|
|
||||||
|
let weather_type = weather_condition_to_type(
|
||||||
|
current.weather.first().map(|w| w.main.as_str()).unwrap_or("Clear")
|
||||||
|
);
|
||||||
|
window.set_weather_type(weather_type);
|
||||||
|
window.set_show_rain(weather_type == 2);
|
||||||
|
|
||||||
|
let forecast_items: Vec<HourData> = forecast_data
|
||||||
|
.list
|
||||||
|
.iter()
|
||||||
|
.take(6)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let dt = chrono::DateTime::from_timestamp(item.dt, 0).unwrap();
|
||||||
|
let hour_str = dt.format("%H:%M").to_string();
|
||||||
|
HourData {
|
||||||
|
hour: SharedString::from(hour_str),
|
||||||
|
temp: item.main.temp.round() as i32,
|
||||||
|
icon_type: weather_condition_to_type(
|
||||||
|
item.weather.first().map(|w| w.main.as_str()).unwrap_or("Clear")
|
||||||
|
),
|
||||||
|
is_now: i == 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
window.set_forecast(ModelRc::from(Rc::new(VecModel::from(forecast_items))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Main
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
|
// Load API key from: 1) saved config, 2) .env, 3) empty
|
||||||
|
let mut api_key = load_api_key()
|
||||||
|
.or_else(|| std::env::var("open_weader_api").ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// If no key, show dialog
|
||||||
|
if api_key.is_empty() {
|
||||||
|
let dialog = ApiKeyDialog::new()?;
|
||||||
|
let dialog_weak = dialog.as_weak();
|
||||||
|
let submitted_key = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
||||||
|
dialog.on_submit_key({
|
||||||
|
let dialog_weak = dialog_weak.clone();
|
||||||
|
let submitted_key = submitted_key.clone();
|
||||||
|
move |key| {
|
||||||
|
*submitted_key.lock().unwrap() = key.to_string();
|
||||||
|
if let Some(d) = dialog_weak.upgrade() {
|
||||||
|
d.hide().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.on_skip({
|
||||||
|
let dialog_weak = dialog_weak.clone();
|
||||||
|
move || {
|
||||||
|
if let Some(d) = dialog_weak.upgrade() {
|
||||||
|
d.hide().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.run()?;
|
||||||
|
api_key = submitted_key.lock().unwrap().clone();
|
||||||
|
|
||||||
|
// Save if not empty
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
save_api_key(&api_key).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let main_window = MainWindow::new()?;
|
||||||
|
|
||||||
|
// Settings callback: reopen dialog to change API key and reload
|
||||||
|
let main_weak = main_window.as_weak();
|
||||||
|
main_window.on_open_settings(move || {
|
||||||
|
let main_weak = main_weak.clone();
|
||||||
|
|
||||||
|
let dialog = match ApiKeyDialog::new() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dialog_weak = dialog.as_weak();
|
||||||
|
let submitted_key = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
||||||
|
dialog.on_submit_key({
|
||||||
|
let dialog_weak = dialog_weak.clone();
|
||||||
|
let submitted_key = submitted_key.clone();
|
||||||
|
move |key| {
|
||||||
|
*submitted_key.lock().unwrap() = key.to_string();
|
||||||
|
if let Some(d) = dialog_weak.upgrade() {
|
||||||
|
d.hide().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.on_skip({
|
||||||
|
let dialog_weak = dialog_weak.clone();
|
||||||
|
move || {
|
||||||
|
if let Some(d) = dialog_weak.upgrade() {
|
||||||
|
d.hide().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run dialog (blocks)
|
||||||
|
let dialog_weak_for_thread = dialog.as_weak();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some(d) = dialog_weak_for_thread.upgrade() {
|
||||||
|
d.run().ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for dialog to close, then fetch new data
|
||||||
|
let submitted_key_clone = submitted_key.clone();
|
||||||
|
slint::invoke_from_event_loop(move || {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Small delay to ensure dialog is closed
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
|
let new_key = submitted_key_clone.lock().unwrap().clone();
|
||||||
|
|
||||||
|
if !new_key.is_empty() {
|
||||||
|
save_api_key(&new_key).ok();
|
||||||
|
eprintln!("✓ API key salvata, ricarico dati...");
|
||||||
|
|
||||||
|
// Fetch new data in async context
|
||||||
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||||
|
let (current, forecast_data) = match (
|
||||||
|
fetch_current_weather(&new_key).await,
|
||||||
|
fetch_forecast(&new_key).await,
|
||||||
|
) {
|
||||||
|
(Ok(c), Ok(f)) => {
|
||||||
|
eprintln!("✓ Dati aggiornati con successo");
|
||||||
|
(c, f)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("⚠ API fallita, uso dati fake");
|
||||||
|
(fake_current_weather(), fake_forecast())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update UI from event loop
|
||||||
|
let main_weak_inner = main_weak.clone();
|
||||||
|
slint::invoke_from_event_loop(move || {
|
||||||
|
if let Some(w) = main_weak_inner.upgrade() {
|
||||||
|
update_weather_ui(&w, current, forecast_data);
|
||||||
|
}
|
||||||
|
}).ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to fetch live data, fallback to fake on error
|
||||||
|
let (current, forecast_data) = match (
|
||||||
|
fetch_current_weather(&api_key).await,
|
||||||
|
fetch_forecast(&api_key).await,
|
||||||
|
) {
|
||||||
|
(Ok(c), Ok(f)) => {
|
||||||
|
eprintln!("✓ Using live weather data from API");
|
||||||
|
(c, f)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("⚠ API failed, using fallback fake data");
|
||||||
|
(fake_current_weather(), fake_forecast())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update UI with weather data
|
||||||
|
update_weather_ui(&main_window, current, forecast_data);
|
||||||
|
|
||||||
|
// Set initial time based on real time
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let current_hour = now.hour() as f32 + (now.minute() as f32 / 60.0);
|
||||||
|
main_window.set_time_of_day(current_hour);
|
||||||
|
|
||||||
|
// Animation timer
|
||||||
|
let weak = main_window.as_weak();
|
||||||
|
let timer = Timer::default();
|
||||||
|
timer.start(TimerMode::Repeated, Duration::from_millis(33), move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
w.set_tick(w.get_tick() + 0.033);
|
||||||
|
|
||||||
|
let time = w.get_time_of_day();
|
||||||
|
let (top, bottom) = get_sky_colors(time);
|
||||||
|
w.set_sky_top(top);
|
||||||
|
w.set_sky_bottom(bottom);
|
||||||
|
w.set_sun_y_factor(get_sun_y(time));
|
||||||
|
});
|
||||||
|
|
||||||
|
main_window.run()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
850
ui/app-window.slint
Normal file
850
ui/app-window.slint
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
struct HourData {
|
||||||
|
hour: string,
|
||||||
|
temp: int,
|
||||||
|
icon-type: int,
|
||||||
|
is-now: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ API Key Dialog ═════════════════════════════════════════
|
||||||
|
export component ApiKeyDialog inherits Window {
|
||||||
|
title: "OpenWeatherMap API Key";
|
||||||
|
preferred-width: 480px;
|
||||||
|
preferred-height: 320px;
|
||||||
|
background: @linear-gradient(180deg, #1a1a2e, #0f0f1e);
|
||||||
|
|
||||||
|
in-out property <string> api-key-input: "";
|
||||||
|
callback submit-key(string);
|
||||||
|
callback skip;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 40px;
|
||||||
|
spacing: 20px;
|
||||||
|
alignment: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Configurazione API Key";
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Inserisci la tua chiave API di OpenWeatherMap\nper visualizzare i dati meteo in tempo reale.";
|
||||||
|
color: #ffffffaa;
|
||||||
|
font-size: 13px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 10px; }
|
||||||
|
|
||||||
|
// Input field
|
||||||
|
Rectangle {
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff0a;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #ffffff22;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding: 14px;
|
||||||
|
|
||||||
|
input := TextInput {
|
||||||
|
text <=> root.api-key-input;
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
horizontal-alignment: left;
|
||||||
|
single-line: true;
|
||||||
|
|
||||||
|
accepted => {
|
||||||
|
root.submit-key(self.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Ottieni una chiave gratuita su:\nopenweathermap.org/api";
|
||||||
|
color: #66bbff;
|
||||||
|
font-size: 11px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { vertical-stretch: 1; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff12;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #ffffff1a;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Salta (usa dati fake)";
|
||||||
|
color: #ffffffaa;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.skip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: @linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
drop-shadow-blur: 12px;
|
||||||
|
drop-shadow-color: #4facfe44;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Salva e Continua";
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.submit-key(root.api-key-input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Decorative Cloud ────────────────────────────────────────
|
||||||
|
component CloudShape inherits Rectangle {
|
||||||
|
in property <float> tick: 0;
|
||||||
|
in property <float> speed: 0.3;
|
||||||
|
in property <float> phase: 0;
|
||||||
|
in property <float> base-opacity: 0.25;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
opacity: base-opacity;
|
||||||
|
property <length> bob: Math.sin((tick * speed * 0.7 + phase) * 1rad) * 6px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.15; y: parent.height * 0.35 + parent.bob;
|
||||||
|
width: parent.width * 0.7; height: parent.height * 0.55;
|
||||||
|
border-radius: self.height / 2; background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.30; y: parent.height * 0.05 + parent.bob;
|
||||||
|
width: parent.width * 0.4; height: parent.height * 0.55;
|
||||||
|
border-radius: self.height / 2; background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.05; y: parent.height * 0.40 + parent.bob;
|
||||||
|
width: parent.width * 0.32; height: parent.height * 0.42;
|
||||||
|
border-radius: self.height / 2; background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.58; y: parent.height * 0.28 + parent.bob;
|
||||||
|
width: parent.width * 0.32; height: parent.height * 0.42;
|
||||||
|
border-radius: self.height / 2; background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rain Drop ───────────────────────────────────────────────
|
||||||
|
component RainDrop inherits Rectangle {
|
||||||
|
in property <float> tick;
|
||||||
|
in property <float> speed: 3.0;
|
||||||
|
in property <float> phase: 0;
|
||||||
|
|
||||||
|
width: 2px; height: 18px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: @linear-gradient(180deg, #aaddff00, #aaddffaa);
|
||||||
|
opacity: Math.max(0, Math.sin((tick * speed + phase) * 1rad)) * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Weather Display Icons (drawn with shapes) ══════════════
|
||||||
|
|
||||||
|
// ─── Sun Display ─────────────────────────────────────────────
|
||||||
|
component SunDisplay inherits Rectangle {
|
||||||
|
in property <float> tick;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
// Pulsing outer ring 1
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.92; height: parent.height * 0.92;
|
||||||
|
x: parent.width * 0.04; y: parent.height * 0.04;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: transparent;
|
||||||
|
border-width: 1.5px; border-color: #FFD70022;
|
||||||
|
opacity: 0.4 + Math.sin(tick * 1.5rad) * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulsing outer ring 2 (counter-phase)
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.82; height: parent.height * 0.82;
|
||||||
|
x: parent.width * 0.09; y: parent.height * 0.09;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: transparent;
|
||||||
|
border-width: 1.5px; border-color: #FFD70033;
|
||||||
|
opacity: 0.5 + Math.sin((tick * 2.0 + 1.0) * 1rad) * 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulsing outer ring 3
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.72; height: parent.height * 0.72;
|
||||||
|
x: parent.width * 0.14; y: parent.height * 0.14;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: transparent;
|
||||||
|
border-width: 1px; border-color: #FFD70044;
|
||||||
|
opacity: 0.4 + Math.sin((tick * 2.5 + 2.0) * 1rad) * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm glow halo
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.65; height: parent.height * 0.65;
|
||||||
|
x: parent.width * 0.175; y: parent.height * 0.175;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: #FFD70018;
|
||||||
|
drop-shadow-blur: 15px; drop-shadow-color: #FFD70033;
|
||||||
|
opacity: 0.6 + Math.sin(tick * 1.8rad) * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main circle
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.50; height: parent.height * 0.50;
|
||||||
|
x: parent.width * 0.25; y: parent.height * 0.25;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: @radial-gradient(circle, #FFE066 0%, #FFD700 50%, #FFA500 100%);
|
||||||
|
drop-shadow-blur: 22px; drop-shadow-color: #FFD70088;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bright core
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.22; height: parent.height * 0.22;
|
||||||
|
x: parent.width * 0.39; y: parent.height * 0.39;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: #FFF8E1;
|
||||||
|
opacity: 0.55 + Math.sin(tick * 4rad) * 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cloud Display ───────────────────────────────────────────
|
||||||
|
component CloudDisplay inherits Rectangle {
|
||||||
|
in property <float> tick;
|
||||||
|
background: transparent;
|
||||||
|
property <length> bob: Math.sin(tick * 0.8rad) * 3px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.10; y: parent.height * 0.45 + parent.bob;
|
||||||
|
width: parent.width * 0.80; height: parent.height * 0.42;
|
||||||
|
border-radius: self.height / 2;
|
||||||
|
background: @linear-gradient(180deg, #ffffff, #e0e0ee);
|
||||||
|
drop-shadow-blur: 12px; drop-shadow-color: #00000015;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.14; y: parent.height * 0.28 + parent.bob;
|
||||||
|
width: parent.width * 0.34; height: parent.height * 0.42;
|
||||||
|
border-radius: self.height / 2; background: #ffffff;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.30; y: parent.height * 0.08 + parent.bob;
|
||||||
|
width: parent.width * 0.40; height: parent.height * 0.52;
|
||||||
|
border-radius: self.height / 2; background: #ffffff;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.52; y: parent.height * 0.22 + parent.bob;
|
||||||
|
width: parent.width * 0.34; height: parent.height * 0.42;
|
||||||
|
border-radius: self.height / 2; background: #fafaff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rain Display ────────────────────────────────────────────
|
||||||
|
component RainDisplay inherits Rectangle {
|
||||||
|
in property <float> tick;
|
||||||
|
background: transparent;
|
||||||
|
property <length> bob: Math.sin(tick * 0.6rad) * 2px;
|
||||||
|
|
||||||
|
// Dark cloud
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.10; y: parent.height * 0.10 + bob;
|
||||||
|
width: parent.width * 0.80; height: parent.height * 0.32;
|
||||||
|
border-radius: self.height / 2; background: #8899aa;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.20; y: parent.height * 0.0 + bob;
|
||||||
|
width: parent.width * 0.32; height: parent.height * 0.30;
|
||||||
|
border-radius: self.height / 2; background: #8899aa;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.42; y: parent.height * -0.04 + bob;
|
||||||
|
width: parent.width * 0.34; height: parent.height * 0.32;
|
||||||
|
border-radius: self.height / 2; background: #8899aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated drops
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.22; y: parent.height * 0.50;
|
||||||
|
width: 2px; height: 14px; border-radius: 1px; background: #aaddff;
|
||||||
|
opacity: Math.max(0.0, Math.sin((tick * 3.0) * 1rad)) * 0.7;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.38; y: parent.height * 0.55;
|
||||||
|
width: 2px; height: 12px; border-radius: 1px; background: #aaddff;
|
||||||
|
opacity: Math.max(0.0, Math.sin((tick * 3.2 + 1.5) * 1rad)) * 0.7;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.54; y: parent.height * 0.48;
|
||||||
|
width: 2px; height: 14px; border-radius: 1px; background: #aaddff;
|
||||||
|
opacity: Math.max(0.0, Math.sin((tick * 2.8 + 3.0) * 1rad)) * 0.7;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.70; y: parent.height * 0.53;
|
||||||
|
width: 2px; height: 12px; border-radius: 1px; background: #aaddff;
|
||||||
|
opacity: Math.max(0.0, Math.sin((tick * 3.5 + 2.0) * 1rad)) * 0.6;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.30; y: parent.height * 0.68;
|
||||||
|
width: 2px; height: 10px; border-radius: 1px; background: #aaddff;
|
||||||
|
opacity: Math.max(0.0, Math.sin((tick * 3.1 + 4.0) * 1rad)) * 0.5;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width * 0.62; y: parent.height * 0.70;
|
||||||
|
width: 2px; height: 10px; border-radius: 1px; background: #aaddff;
|
||||||
|
opacity: Math.max(0.0, Math.sin((tick * 2.9 + 5.5) * 1rad)) * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ UI Components ══════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─── Forecast Card (glassmorphism) ──────────────────────────
|
||||||
|
component ForecastCard inherits Rectangle {
|
||||||
|
in property <string> hour;
|
||||||
|
in property <int> temp;
|
||||||
|
in property <int> icon-type;
|
||||||
|
in property <bool> is-now: false;
|
||||||
|
in property <float> tick;
|
||||||
|
|
||||||
|
width: 64px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: is-now ? #ffffff22 : #ffffff0c;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: is-now ? #ffffff30 : #ffffff0a;
|
||||||
|
drop-shadow-blur: is-now ? 12px : 0px;
|
||||||
|
drop-shadow-color: #ffffff11;
|
||||||
|
|
||||||
|
animate background { duration: 400ms; easing: ease-in-out; }
|
||||||
|
animate border-color { duration: 400ms; easing: ease-in-out; }
|
||||||
|
animate drop-shadow-blur { duration: 400ms; easing: ease-in-out; }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 6px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: hour;
|
||||||
|
color: is-now ? #ffffffee : #ffffff88;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: is-now ? 600 : 400;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini icon area
|
||||||
|
Rectangle {
|
||||||
|
height: 30px;
|
||||||
|
|
||||||
|
if icon-type == 0: Rectangle {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
x: (parent.width - self.width) / 2; y: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: @radial-gradient(circle, #FFE066 0%, #FFA500 100%);
|
||||||
|
drop-shadow-blur: 6px; drop-shadow-color: #FFD70055;
|
||||||
|
}
|
||||||
|
|
||||||
|
if icon-type == 1: Rectangle {
|
||||||
|
x: (parent.width - 28px) / 2; y: 4px;
|
||||||
|
width: 28px; height: 18px; background: transparent;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: 2px; y: 8px; width: 24px; height: 10px;
|
||||||
|
border-radius: 5px; background: #ffffffbb;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: 6px; y: 2px; width: 14px; height: 12px;
|
||||||
|
border-radius: 6px; background: #ffffffbb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if icon-type == 2: Rectangle {
|
||||||
|
x: (parent.width - 28px) / 2; y: 2px;
|
||||||
|
width: 28px; height: 24px; background: transparent;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: 2px; y: 4px; width: 24px; height: 10px;
|
||||||
|
border-radius: 5px; background: #8899aacc;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: 6px; y: 0px; width: 14px; height: 10px;
|
||||||
|
border-radius: 5px; background: #8899aacc;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: 8px; y: 16px; width: 1.5px; height: 6px;
|
||||||
|
border-radius: 0.75px; background: #aaddff88;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: 16px; y: 17px; width: 1.5px; height: 5px;
|
||||||
|
border-radius: 0.75px; background: #aaddff88;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "\{temp}°";
|
||||||
|
color: white;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Info Pill (glassmorphism + accent) ─────────────────────
|
||||||
|
component InfoPill inherits Rectangle {
|
||||||
|
in property <string> label;
|
||||||
|
in property <string> value;
|
||||||
|
in property <color> accent: #66bbff;
|
||||||
|
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff0c;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #ffffff0a;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
// Accent dot
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: accent;
|
||||||
|
drop-shadow-blur: 4px; drop-shadow-color: accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: value;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: label;
|
||||||
|
color: #ffffff55;
|
||||||
|
font-size: 9px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Premium Slider ─────────────────────────────────────────
|
||||||
|
component PremiumSlider inherits Rectangle {
|
||||||
|
in-out property <float> value: 12;
|
||||||
|
in property <float> minimum: 0;
|
||||||
|
in property <float> maximum: 24;
|
||||||
|
|
||||||
|
height: 44px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
property <float> ratio: Math.clamp((value - minimum) / (maximum - minimum), 0, 1);
|
||||||
|
|
||||||
|
// Track
|
||||||
|
Rectangle {
|
||||||
|
y: (parent.height - 4px) / 2;
|
||||||
|
width: parent.width; height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #ffffff12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
Rectangle {
|
||||||
|
y: (parent.height - 4px) / 2;
|
||||||
|
width: parent.width * ratio; height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: @linear-gradient(90deg, #ffffff20, #ffffff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumb glow
|
||||||
|
Rectangle {
|
||||||
|
x: (parent.width - 28px) * ratio;
|
||||||
|
y: (parent.height - 28px) / 2;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff08;
|
||||||
|
drop-shadow-blur: 16px; drop-shadow-color: #ffffff22;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumb
|
||||||
|
Rectangle {
|
||||||
|
x: (parent.width - 22px) * ratio;
|
||||||
|
y: (parent.height - 22px) / 2;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: @radial-gradient(circle, #ffffff 0%, #e8e8f0 100%);
|
||||||
|
drop-shadow-blur: 8px; drop-shadow-color: #00000022;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
x: 8px; y: 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #888899;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.value = root.minimum + (root.maximum - root.minimum) * Math.clamp(self.mouse-x / root.width, 0, 1);
|
||||||
|
}
|
||||||
|
moved => {
|
||||||
|
if self.pressed {
|
||||||
|
root.value = root.minimum + (root.maximum - root.minimum) * Math.clamp(self.mouse-x / root.width, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Separator ──────────────────────────────────────────────
|
||||||
|
component Separator inherits HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 40px; height: 1px;
|
||||||
|
border-radius: 0.5px;
|
||||||
|
background: @linear-gradient(90deg, #ffffff00, #ffffff22, #ffffff00);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
// Main Window
|
||||||
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
export component MainWindow inherits Window {
|
||||||
|
title: "Meteo Milano";
|
||||||
|
preferred-width: 420px;
|
||||||
|
preferred-height: 780px;
|
||||||
|
background: black;
|
||||||
|
|
||||||
|
in-out property <float> tick: 0;
|
||||||
|
in-out property <float> time-of-day: 14.0;
|
||||||
|
in-out property <color> sky-top: #1e90ff;
|
||||||
|
in-out property <color> sky-bottom: #87CEEB;
|
||||||
|
in-out property <int> current-temp: 22;
|
||||||
|
in-out property <string> weather-desc: "Parzialmente Nuvoloso";
|
||||||
|
in-out property <string> humidity: "65%";
|
||||||
|
in-out property <string> wind: "12 km/h";
|
||||||
|
in-out property <string> feels-like: "20°";
|
||||||
|
in-out property <int> weather-type: 1;
|
||||||
|
in-out property <float> sun-y-factor: 0.3;
|
||||||
|
in-out property <[HourData]> forecast: [];
|
||||||
|
in-out property <bool> show-rain: false;
|
||||||
|
|
||||||
|
callback open-settings;
|
||||||
|
|
||||||
|
// ── Background ──
|
||||||
|
Rectangle {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: @linear-gradient(180deg, sky-top, sky-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sky Sun ──
|
||||||
|
Rectangle {
|
||||||
|
property <length> s: 90px;
|
||||||
|
x: root.width * 0.70;
|
||||||
|
y: root.height * root.sun-y-factor;
|
||||||
|
width: s; height: s;
|
||||||
|
border-radius: s / 2;
|
||||||
|
background: @radial-gradient(circle, #FFD700 0%, #FF8C0060 60%, #FF450000 100%);
|
||||||
|
opacity: weather-type == 0 ? 0.95 : weather-type == 1 ? 0.35 : 0.0;
|
||||||
|
drop-shadow-blur: 40px + Math.sin(tick * 2rad) * 10px;
|
||||||
|
drop-shadow-color: #FFD70055;
|
||||||
|
animate opacity { duration: 600ms; easing: ease-in-out; }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.35; height: parent.height * 0.35;
|
||||||
|
x: parent.width * 0.325; y: parent.height * 0.325;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: white;
|
||||||
|
opacity: 0.35 + Math.sin(tick * 3rad) * 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moon ──
|
||||||
|
Rectangle {
|
||||||
|
property <length> m: 48px;
|
||||||
|
x: root.width * 0.76; y: root.height * 0.10;
|
||||||
|
width: m; height: m;
|
||||||
|
border-radius: m / 2;
|
||||||
|
background: #E8E8D0;
|
||||||
|
opacity: time-of-day < 5.5 || time-of-day > 20.5 ? 0.85 : 0.0;
|
||||||
|
drop-shadow-blur: 18px; drop-shadow-color: #E8E8D044;
|
||||||
|
animate opacity { duration: 800ms; easing: ease-in-out; }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width * 0.7; height: parent.height * 0.7;
|
||||||
|
x: parent.width * 0.38; y: parent.height * 0.08;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: sky-top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Floating clouds ──
|
||||||
|
CloudShape {
|
||||||
|
width: 150px; height: 75px;
|
||||||
|
x: root.width * 0.02 + Math.sin((tick * 0.2) * 1rad) * 25px;
|
||||||
|
y: root.height * 0.05;
|
||||||
|
tick: root.tick; speed: 0.3; phase: 0; base-opacity: 0.20;
|
||||||
|
}
|
||||||
|
CloudShape {
|
||||||
|
width: 115px; height: 58px;
|
||||||
|
x: root.width * 0.52 + Math.sin((tick * 0.15 + 2.0) * 1rad) * 20px;
|
||||||
|
y: root.height * 0.12;
|
||||||
|
tick: root.tick; speed: 0.25; phase: 2.0; base-opacity: 0.16;
|
||||||
|
}
|
||||||
|
CloudShape {
|
||||||
|
width: 100px; height: 50px;
|
||||||
|
x: root.width * 0.25 + Math.sin((tick * 0.18 + 4.5) * 1rad) * 30px;
|
||||||
|
y: root.height * 0.19;
|
||||||
|
tick: root.tick; speed: 0.35; phase: 4.5; base-opacity: 0.13;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rain ──
|
||||||
|
if show-rain: Rectangle {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
RainDrop { x: root.width * 0.06; y: root.height * 0.28; tick: root.tick; speed: 3.2; phase: 0.0; }
|
||||||
|
RainDrop { x: root.width * 0.13; y: root.height * 0.40; tick: root.tick; speed: 2.8; phase: 0.7; }
|
||||||
|
RainDrop { x: root.width * 0.20; y: root.height * 0.34; tick: root.tick; speed: 3.5; phase: 1.4; }
|
||||||
|
RainDrop { x: root.width * 0.28; y: root.height * 0.48; tick: root.tick; speed: 3.0; phase: 2.1; }
|
||||||
|
RainDrop { x: root.width * 0.35; y: root.height * 0.37; tick: root.tick; speed: 3.3; phase: 2.8; }
|
||||||
|
RainDrop { x: root.width * 0.42; y: root.height * 0.44; tick: root.tick; speed: 2.9; phase: 3.5; }
|
||||||
|
RainDrop { x: root.width * 0.50; y: root.height * 0.31; tick: root.tick; speed: 3.4; phase: 4.2; }
|
||||||
|
RainDrop { x: root.width * 0.57; y: root.height * 0.46; tick: root.tick; speed: 3.1; phase: 4.9; }
|
||||||
|
RainDrop { x: root.width * 0.64; y: root.height * 0.39; tick: root.tick; speed: 2.7; phase: 5.6; }
|
||||||
|
RainDrop { x: root.width * 0.72; y: root.height * 0.50; tick: root.tick; speed: 3.6; phase: 6.3; }
|
||||||
|
RainDrop { x: root.width * 0.80; y: root.height * 0.35; tick: root.tick; speed: 3.0; phase: 7.0; }
|
||||||
|
RainDrop { x: root.width * 0.88; y: root.height * 0.42; tick: root.tick; speed: 3.3; phase: 7.7; }
|
||||||
|
RainDrop { x: root.width * 0.09; y: root.height * 0.54; tick: root.tick; speed: 2.8; phase: 8.4; }
|
||||||
|
RainDrop { x: root.width * 0.38; y: root.height * 0.57; tick: root.tick; speed: 3.1; phase: 9.8; }
|
||||||
|
RainDrop { x: root.width * 0.68; y: root.height * 0.55; tick: root.tick; speed: 3.4; phase: 11.2; }
|
||||||
|
RainDrop { x: root.width * 0.84; y: root.height * 0.60; tick: root.tick; speed: 3.2; phase: 11.9; }
|
||||||
|
RainDrop { x: root.width * 0.17; y: root.height * 0.63; tick: root.tick; speed: 2.6; phase: 1.1; }
|
||||||
|
RainDrop { x: root.width * 0.46; y: root.height * 0.66; tick: root.tick; speed: 3.7; phase: 3.3; }
|
||||||
|
RainDrop { x: root.width * 0.76; y: root.height * 0.62; tick: root.tick; speed: 2.9; phase: 5.0; }
|
||||||
|
RainDrop { x: root.width * 0.93; y: root.height * 0.52; tick: root.tick; speed: 3.1; phase: 8.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main content ──
|
||||||
|
VerticalLayout {
|
||||||
|
padding-top: 48px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
spacing: 2px;
|
||||||
|
|
||||||
|
// City name
|
||||||
|
Text {
|
||||||
|
text: "MILANO";
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Sabato 31 Gennaio";
|
||||||
|
color: #ffffff77;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { vertical-stretch: 1; }
|
||||||
|
|
||||||
|
// Central weather icon
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
if weather-type == 0: SunDisplay {
|
||||||
|
width: 100px; height: 100px;
|
||||||
|
tick: root.tick;
|
||||||
|
}
|
||||||
|
if weather-type == 1: CloudDisplay {
|
||||||
|
width: 110px; height: 70px;
|
||||||
|
tick: root.tick;
|
||||||
|
}
|
||||||
|
if weather-type == 2: RainDisplay {
|
||||||
|
width: 110px; height: 80px;
|
||||||
|
tick: root.tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
|
// Temperature
|
||||||
|
Text {
|
||||||
|
text: "\{current-temp}°";
|
||||||
|
color: white;
|
||||||
|
font-size: 92px;
|
||||||
|
font-weight: 200;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: weather-desc;
|
||||||
|
color: #ffffffbb;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 16px; }
|
||||||
|
Separator { }
|
||||||
|
Rectangle { height: 16px; }
|
||||||
|
|
||||||
|
// Info pills
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
InfoPill { label: "UMIDITA"; value: humidity; accent: #66bbff; }
|
||||||
|
InfoPill { label: "VENTO"; value: wind; accent: #88ddaa; }
|
||||||
|
InfoPill { label: "PERCEPITA"; value: feels-like; accent: #ffaa66; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 20px; }
|
||||||
|
|
||||||
|
// Forecast section
|
||||||
|
Text {
|
||||||
|
text: "PREVISIONI ORARIE";
|
||||||
|
color: #ffffff55;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 7px;
|
||||||
|
for data in forecast: ForecastCard {
|
||||||
|
hour: data.hour;
|
||||||
|
temp: data.temp;
|
||||||
|
icon-type: data.icon-type;
|
||||||
|
is-now: data.is-now;
|
||||||
|
tick: root.tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { vertical-stretch: 2; }
|
||||||
|
|
||||||
|
// Time control
|
||||||
|
Text {
|
||||||
|
text: "Scorri per cambiare l'ora";
|
||||||
|
color: #ffffff33;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 4px; }
|
||||||
|
|
||||||
|
PremiumSlider {
|
||||||
|
minimum: 0;
|
||||||
|
maximum: 23.9;
|
||||||
|
value <=> root.time-of-day;
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
Text { text: "00:00"; color: #ffffff33; font-size: 9px; font-weight: 400; }
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
Text { text: "12:00"; color: #ffffff33; font-size: 9px; font-weight: 400; horizontal-alignment: center; }
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
Text { text: "24:00"; color: #ffffff33; font-size: 9px; font-weight: 400; horizontal-alignment: right; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings button (top-right) ──
|
||||||
|
Rectangle {
|
||||||
|
x: root.width - 52px;
|
||||||
|
y: 12px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff12;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #ffffff1a;
|
||||||
|
|
||||||
|
// Gear icon (simplified)
|
||||||
|
Rectangle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
x: 10px;
|
||||||
|
y: 10px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
x: 4px; y: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: #ffffffaa;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 3px; height: 6px;
|
||||||
|
x: 8.5px; y: 0px;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
background: #ffffffaa;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 3px; height: 6px;
|
||||||
|
x: 8.5px; y: 14px;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
background: #ffffffaa;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 6px; height: 3px;
|
||||||
|
x: 0px; y: 8.5px;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
background: #ffffffaa;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 6px; height: 3px;
|
||||||
|
x: 14px; y: 8.5px;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
background: #ffffffaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => {
|
||||||
|
root.open-settings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user