build easy app

This commit is contained in:
Attilio Greco
2026-02-01 00:00:02 +01:00
parent a2f6f48dc7
commit ea1c6e0614
7 changed files with 8076 additions and 0 deletions

15
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/app-window.slint").expect("Slint build failed");
}

445
src/main.rs Normal file
View 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
View 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();
}
}
}
}