weather-demo: initial commit

This commit is contained in:
Justyna Hudziak 2024-07-11 12:32:25 +02:00 committed by Simon Hausmann
parent dacc57648b
commit 33c84b67b4
51 changed files with 3924 additions and 0 deletions

View file

@ -58,6 +58,11 @@ jobs:
sed -i "s/#wasm# //" Cargo.toml sed -i "s/#wasm# //" Cargo.toml
wasm-pack build --release --target web --no-default-features --features slint/default,chrono wasm-pack build --release --target web --no-default-features --features slint/default,chrono
working-directory: examples/energy-monitor working-directory: examples/energy-monitor
- name: Weather Demo example WASM build
run: |
sed -i "s/#wasm# //" Cargo.toml
wasm-pack build --release --target web --no-default-features --features slint/default,chrono
working-directory: examples/weather-demo
- name: "Upload Demo Artifacts" - name: "Upload Demo Artifacts"
if: ${{ inputs.build_artifacts }} if: ${{ inputs.build_artifacts }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -75,6 +80,7 @@ jobs:
examples/plotter/ examples/plotter/
examples/opengl_underlay/ examples/opengl_underlay/
examples/energy-monitor/ examples/energy-monitor/
examples/weather-demo/
!/**/.gitignore !/**/.gitignore
- name: Clean cache # Otherwise the cache is much too big - name: Clean cache # Otherwise the cache is much too big
run: | run: |

View file

@ -127,3 +127,27 @@ License: CC-BY-4.0
Files: examples/printerdemo_mcu/zephyr/VERSION Files: examples/printerdemo_mcu/zephyr/VERSION
Copyright: Copyright © SixtyFPS GmbH <info@slint.dev> Copyright: Copyright © SixtyFPS GmbH <info@slint.dev>
License: MIT License: MIT
Files: examples/weather-demo/wasm/index.html
Copyright: Copyright © SixtyFPS GmbH <info@slint.dev>
License: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
Files: examples/weather-demo/docs/*.png
Copyright: Copyright © SixtyFPS GmbH <info@slint.dev>
License: MIT
Files: examples/weather-demo/ui/assets/icons/*.svg
Copyright: Fontawesome project <https://fontawesome.com/license/free>
License: CC-BY-4.0
Files: examples/weather-demo/ui/assets/weathericons-font.ttf
Copyright: Weather Icons <http://erikflowers.github.io/weather-icons/>
License: OFL-1.1
Files: examples/weather-demo/android-res/*/ic_launcher.png
Copyright: Copyright © Felgo GmbH <contact@felgo.com>
License: CC-BY-ND-4.0
Files: examples/weather-demo/ui/assets/felgo-logo.svg
Copyright: Copyright © Felgo GmbH <contact@felgo.com>
License: CC-BY-ND-4.0

View file

@ -32,6 +32,7 @@ members = [
'examples/energy-monitor', 'examples/energy-monitor',
'examples/mcu-board-support', 'examples/mcu-board-support',
'examples/uefi-demo', 'examples/uefi-demo',
'examples/weather-demo',
'helper_crates/const-field-offset', 'helper_crates/const-field-offset',
'helper_crates/vtable', 'helper_crates/vtable',
'helper_crates/vtable/macro', 'helper_crates/vtable/macro',

View file

@ -176,6 +176,18 @@ Our implementations of the ["7GUIs"](https://7guis.github.io/7guis/) Tasks.
![Composition of 7GUIs Screenshots](https://user-images.githubusercontent.com/22800467/169002497-5b90e63b-5717-4290-8ac7-c618d9e2a4f1.png "7GUIs") ![Composition of 7GUIs Screenshots](https://user-images.githubusercontent.com/22800467/169002497-5b90e63b-5717-4290-8ac7-c618d9e2a4f1.png "7GUIs")
### [`weather-demo`](./weather-demo)
A simple, cross-platform (Desktop, Android, Wasm) weather application using real weather data from the [OpenWeather](https://openweathermap.org/) API.
| `.slint` Design | Rust Source (Desktop) | Rust Source (Android / Wasm) | Online wasm Preview | Open in SlintPad |
| --- | --- | --- | --- | --- |
| [`main.slint`](./weather-demo/ui/main.slint) | [`main.rs`](./weather-demo/src/main.rs) | [`lib.rs`](./weather-demo/src/lib.rs) | [Online simulation](https://slint.dev/snapshots/master/demos/weather-demo/) | [Preview in Online Code Editor](https://slint.dev/snapshots/master/editor?load_url=https://raw.githubusercontent.com/slint-ui/slint/master/examples/weather-demo/ui/main.slint) |
![Screenshot of the Weather Demo Desktop](./weather-demo/docs/img/desktop-preview.png "Weather Demo Desktop")
![Screenshot of the Weather Demo Desktop](./weather-demo/docs/img/android-preview.png "Weather Demo Android")
### External examples ### External examples
* [Cargo UI](https://github.com/slint-ui/cargo-ui): A rust application that makes use of threads in the background. * [Cargo UI](https://github.com/slint-ui/cargo-ui): A rust application that makes use of threads in the background.

View file

@ -0,0 +1,67 @@
# Copyright © SixtyFPS GmbH <info@slint.dev>
# SPDX-License-Identifier: MIT
[package]
name = "rusty-weather"
version = "1.7.0"
authors = ["FELGO GmbH <contact@felgo.com>"]
edition = "2021"
build = "build.rs"
publish = false
license = "MIT"
[dependencies]
async-std = "1.12.0"
chrono = { version = "0.4.38", optional = true, default-features = false, features = ["clock"] }
directories = "5.0.1"
log = "0.4.21"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.115"
slint = { path = "../../api/rs/slint", features = [ "backend-android-activity-06" ] }
[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))'.dependencies]
env_logger = "0.11.3"
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14.1"
openssl = { version = "0.10", features = ["vendored"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
openweather_sdk = "0.1.8"
tokio = "1.37.0"
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1.7"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
[build-dependencies]
slint-build = { path = "../../api/rs/build" }
[features]
default = ["chrono"]
# Android-activity / wasm support
[lib]
name="rusty_weather_lib"
crate-type = ["cdylib"]
path = "src/lib.rs"
# Andoroid settings
# See more: https://github.com/rust-mobile/cargo-apk?tab=readme-ov-file#manifest
[package.metadata.android]
package = "dev.slint.examples.weatherdemo"
resources = "android-res"
build_targets = [ "aarch64-linux-android" ]
[package.metadata.android.sdk]
min_sdk_version = 29
target_sdk_version = 32
[package.metadata.android.application]
label = "Rusty Weather"
icon = "@mipmap/ic_launcher"
[[package.metadata.android.uses_permission]]
name = "android.permission.INTERNET"

View file

@ -0,0 +1,77 @@
<!-- Copyright © SixtyFPS GmbH <info@slint.dev> ; SPDX-License-Identifier: MIT -->
# Rusty Weather
Rusty Weather is a weather application made by [Felgo](https://felgo.com/).
The application retrieves weather data from the [OpenWeather](https://openweathermap.org/) API to provide:
* Real-time weather data,
* 8-day forecasts data,
* Temperatures at particular times of the day,
* Daily rain amount and rain probability,
* UV index level,
* support for various locations around the globe.
The project demonstrates how to write a cross-platform Rust GUI application using the [Slint](https://slint.dev/) toolkit.
It includes subjects like:
* responsive layouts and adaptions for different screen sizes and orientations,
* providing fully customized look and feel with custom widgets,
* integrating Slint code with the Rust backend code,
* using `async` features with a Slint-based application and combining it with the Tokio runtime.
<br />
<p>
<img src="docs/img/desktop-preview.png" height="415px" />
<img src="docs/img/android-preview.png" height="415px" hspace="5" />
</p>
## Weather data
To enable real weather data from the [OpenWeather](https://openweathermap.org/) API, you must provide the `RUSTY_WEATHER_API_KEY` environment variable with your API key at build time. The [OpenCall API](https://openweathermap.org/price#onecall) subscription is required.
If you do not provide the key, the application loads the dummy data instead.
**Note:** You cannot use real weather data for the WebAssembly target.
# Supported platforms
## Desktop
The application runs on all desktop platforms (Windows, Linux and macOS).
To start the application, execute:
```
cargo run
```
## Android
To be able to compile the application for Android, you must follow an initial setup. The instruction is available in [Slint's documentation](https://snapshots.slint.dev/master/docs/rust/slint/android/#building-and-deploying).
***Note:*** To build `openssl` for Android, you must ensure that the proper development libraries are available in the system and provided in the `PATH` variable.
To start the application, execute:
```
cargo apk run --lib
```
## WebAssembly
It is also possible to embed the application in a web page. You need to install the `wasm-pack` crate for this.
```
cargo install wasm-pack
```
To build the application, execute:
```
wasm-pack build --target web --out-dir <output-dir>/pkg
```
To run locally:
```
cp <source-dir>/wasm/index.html <output-dir>/ # you can also provide your HTML file
cd <output-dir> & python3 -m http.server
```
Now, you can access the application at http://localhost:8000/.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,10 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use std::env;
fn main() {
env::set_var("SLINT_ENABLE_EXPERIMENTAL_FEATURES", "true");
slint_build::compile("ui/main.slint").unwrap();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -0,0 +1,83 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use std::sync::{Arc, Mutex};
use crate::ui::*;
use crate::weather;
use weather::DummyWeatherController;
use weather::{WeatherControllerPointer, WeatherControllerSharedPointer, WeatherDisplayController};
#[cfg(not(target_arch = "wasm32"))]
use weather::OpenWeatherController;
pub struct AppHandler {
weather_controller: WeatherControllerSharedPointer,
weather_display_controller: WeatherDisplayController,
window: Option<AppWindow>,
support_add_city: bool,
}
impl AppHandler {
pub fn new() -> Self {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut support_add_city = false;
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut data_controller_opt: Option<WeatherControllerPointer> = None;
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(api_key) = std::option_env!("RUSTY_WEATHER_API_KEY") {
data_controller_opt = Some(Box::new(OpenWeatherController::new(api_key.into())));
support_add_city = true;
}
}
let data_controller = match data_controller_opt {
Some(data_contoller_some) => data_contoller_some,
None => {
log::info!("Weather API key not provided. Using dummy data.");
Box::new(DummyWeatherController::new())
}
};
let data_controller: WeatherControllerSharedPointer = Arc::new(Mutex::new(data_controller));
Self {
weather_controller: data_controller.clone(),
weather_display_controller: WeatherDisplayController::new(&data_controller),
window: None,
support_add_city,
}
}
pub fn save(&self) {
log::debug!("Saving state");
if let Err(e) = self.weather_controller.lock().unwrap().save() {
log::warn!("Error while saving state: {}", e)
}
}
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn reload(&self) {
log::debug!("Reloading state");
if let Some(window) = &self.window {
self.weather_display_controller.refresh(window); // load new weather data
} else {
log::warn!("Cannot reload state, window not available.");
}
}
pub fn initialize_ui(&mut self) {
let window = AppWindow::new().expect("Cannot create main window!");
self.weather_display_controller.initialize_ui(&window, self.support_add_city);
self.window = Some(window);
}
pub fn run(&self) -> Result<(), slint::PlatformError> {
let window = self.window.as_ref().expect("Cannot access main window!");
self.weather_display_controller.load(window);
window.run()
}
}

View file

@ -0,0 +1,90 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#![cfg(any(target_os = "android", target_arch = "wasm32"))]
pub mod ui {
slint::include_modules!();
}
mod app_main;
mod weather;
use crate::app_main::AppHandler;
// Android
#[cfg(target_os = "android")]
use {
crate::android_activity::{MainEvent, PollEvent},
core::cell::RefCell,
slint::android::android_activity,
std::rc::Rc,
};
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(android_app: slint::android::AndroidApp) -> Result<(), slint::PlatformError> {
android_logger::init_once(android_logger::Config::default().with_max_level(
if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info },
));
let app_handler = Rc::new(RefCell::new(AppHandler::new()));
// initialize android before creating main window
slint::android::init_with_event_listener(android_app, {
let app_handler = app_handler.clone();
move |event| match event {
PollEvent::Main(main_event) => match main_event {
MainEvent::Start => {
app_handler.borrow().reload();
}
MainEvent::Resume { .. } => {
app_handler.borrow().reload();
}
MainEvent::SaveState { .. } => {
app_handler.borrow().save();
}
_ => {}
},
_ => {}
}
})
.unwrap();
{
// create main window here
let mut app_handler = app_handler.borrow_mut();
app_handler.initialize_ui();
}
let app_handler = app_handler.borrow();
app_handler.run()
}
// Wasm
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn main() {
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
console_log::init_with_level(if cfg!(debug_assertions) {
log::Level::Debug
} else {
log::Level::Info
})
.ok();
let mut app_handler = AppHandler::new();
app_handler.initialize_ui();
let res = app_handler.run();
app_handler.save();
match res {
Ok(()) => {}
Err(e) => {
log::error!("Runtime error: {}", e);
}
}
}

View file

@ -0,0 +1,30 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#![cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
use crate::app_main::AppHandler;
pub mod ui {
slint::include_modules!();
}
mod app_main;
mod weather;
fn main() -> Result<(), slint::PlatformError> {
env_logger::Builder::default()
.filter_level(if cfg!(debug_assertions) {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
})
.init();
let mut app_handler = AppHandler::new();
app_handler.initialize_ui();
let res = app_handler.run();
app_handler.save();
res
}

View file

@ -0,0 +1,620 @@
[
{
"city_data": {
"lat": 52.51703643798828,
"lon": 13.388859748840332,
"city_name": "Berlin"
},
"weather_data": {
"current_data": {
"condition": "PartiallyCloudy",
"description": "broken clouds",
"current_temperature": 15.24,
"detailed_temperature": {
"min": 12.39,
"max": 19.27,
"morning": 14.01,
"day": 18.62,
"evening": 15.83,
"night": 13.8
},
"precipitation": {
"probability": 1,
"rain_volume": 2.77,
"snow_volume": 0
},
"uv_index": 2.3
},
"forecast_data": [
{
"day_name": "d0",
"weather_data": {
"condition": "SunnyRainy",
"description": "moderate rain",
"current_temperature": 16.18,
"detailed_temperature": {
"min": 9.56,
"max": 17.94,
"morning": 10.37,
"day": 16.18,
"evening": 16.71,
"night": 12.45
},
"precipitation": {
"probability": 1,
"rain_volume": 2.77,
"snow_volume": 0
},
"uv_index": 3.82
}
},
{
"day_name": "d1",
"weather_data": {
"condition": "SunnyRainy",
"description": "moderate rain",
"current_temperature": 15.94,
"detailed_temperature": {
"min": 9.34,
"max": 18.96,
"morning": 10.31,
"day": 15.94,
"evening": 16.76,
"night": 12.56
},
"precipitation": {
"probability": 1,
"rain_volume": 2.87,
"snow_volume": 0
},
"uv_index": 3.82
}
},
{
"day_name": "d2",
"weather_data": {
"condition": "Cloudy",
"description": "broken clouds",
"current_temperature": 15.73,
"detailed_temperature": {
"min": 9.17,
"max": 17.9,
"morning": 10.65,
"day": 15.73,
"evening": 17.14,
"night": 12.34
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 5.16
}
},
{
"day_name": "d3",
"weather_data": {
"condition": "Cloudy",
"description": "broken clouds",
"current_temperature": 16.99,
"detailed_temperature": {
"min": 9.47,
"max": 17.69,
"morning": 10.72,
"day": 16.99,
"evening": 16.7,
"night": 14.37
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 5.6
}
},
{
"day_name": "d4",
"weather_data": {
"condition": "SunnyRainy",
"description": "light rain",
"current_temperature": 22.95,
"detailed_temperature": {
"min": 13.23,
"max": 22.95,
"morning": 15.19,
"day": 22.95,
"evening": 20.97,
"night": 17.48
},
"precipitation": {
"probability": 0.2,
"rain_volume": 0.15,
"snow_volume": 0
},
"uv_index": 5.97
}
},
{
"day_name": "d5",
"weather_data": {
"condition": "SunnyRainy",
"description": "light rain",
"current_temperature": 24.07,
"detailed_temperature": {
"min": 16.06,
"max": 24.85,
"morning": 18.61,
"day": 24.07,
"evening": 21.87,
"night": 17.92
},
"precipitation": {
"probability": 0.2,
"rain_volume": 0.19,
"snow_volume": 0
},
"uv_index": 6
}
},
{
"day_name": "d6",
"weather_data": {
"condition": "Cloudy",
"description": "overcast clouds",
"current_temperature": 23.27,
"detailed_temperature": {
"min": 14.73,
"max": 23.27,
"morning": 16.66,
"day": 23.27,
"evening": 22.33,
"night": 18.94
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 6
}
},
{
"day_name": "d7",
"weather_data": {
"condition": "SunnyRainy",
"description": "light rain",
"current_temperature": 25.15,
"detailed_temperature": {
"min": 16.69,
"max": 25.29,
"morning": 19.84,
"day": 25.15,
"evening": 24.47,
"night": 20.54
},
"precipitation": {
"probability": 0.87,
"rain_volume": 0.69,
"snow_volume": 0
},
"uv_index": 6
}
}
]
}
},
{
"city_data": {
"lat": 48.20835494995117,
"lon": 16.37250328063965,
"city_name": "Vienna"
},
"weather_data": {
"current_data": {
"condition": "Rainy",
"description": "light intensity shower rain",
"current_temperature": 17.33,
"detailed_temperature": {
"min": 15.84,
"max": 18.96,
"morning": 17.5,
"day": 17.33,
"evening": 16.89,
"night": 17.5
},
"precipitation": {
"probability": 1,
"rain_volume": 10.78,
"snow_volume": 0
},
"uv_index": 2.63
},
"forecast_data": [
{
"day_name": "d0",
"weather_data": {
"condition": "SunnyRainy",
"description": "moderate rain",
"current_temperature": 17.74,
"detailed_temperature": {
"min": 15.84,
"max": 18.96,
"morning": 17.5,
"day": 17.74,
"evening": 16.89,
"night": 17.5
},
"precipitation": {
"probability": 1,
"rain_volume": 10.78,
"snow_volume": 0
},
"uv_index": 2.63
}
},
{
"day_name": "d1",
"weather_data": {
"condition": "Cloudy",
"description": "overcast clouds",
"current_temperature": 18.14,
"detailed_temperature": {
"min": 15.06,
"max": 20.2,
"morning": 15.76,
"day": 18.14,
"evening": 19.76,
"night": 16.07
},
"precipitation": {
"probability": 0.11,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 4.81
}
},
{
"day_name": "d2",
"weather_data": {
"condition": "SunnyRainy",
"description": "light rain",
"current_temperature": 12.23,
"detailed_temperature": {
"min": 12.08,
"max": 15.59,
"morning": 13.42,
"day": 12.23,
"evening": 13.86,
"night": 12.08
},
"precipitation": {
"probability": 1,
"rain_volume": 1.82,
"snow_volume": 0
},
"uv_index": 0.78
}
},
{
"day_name": "d3",
"weather_data": {
"condition": "SunnyRainy",
"description": "light rain",
"current_temperature": 18.83,
"detailed_temperature": {
"min": 11.93,
"max": 20.12,
"morning": 12.56,
"day": 18.83,
"evening": 19.76,
"night": 14.12
},
"precipitation": {
"probability": 1,
"rain_volume": 1.05,
"snow_volume": 0
},
"uv_index": 2.35
}
},
{
"day_name": "d4",
"weather_data": {
"condition": "MostlyCloudy",
"description": "scattered clouds",
"current_temperature": 21.13,
"detailed_temperature": {
"min": 11.73,
"max": 23.26,
"morning": 11.73,
"day": 21.13,
"evening": 23.24,
"night": 17.1
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 6.17
}
},
{
"day_name": "d5",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 23.55,
"detailed_temperature": {
"min": 14.71,
"max": 26,
"morning": 14.71,
"day": 23.55,
"evening": 25.7,
"night": 20.03
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 7
}
},
{
"day_name": "d6",
"weather_data": {
"condition": "SunnyRainy",
"description": "light rain",
"current_temperature": 21.59,
"detailed_temperature": {
"min": 16.28,
"max": 23.22,
"morning": 16.28,
"day": 21.59,
"evening": 22.76,
"night": 17.24
},
"precipitation": {
"probability": 0.38,
"rain_volume": 0.27,
"snow_volume": 0
},
"uv_index": 7
}
},
{
"day_name": "d7",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 24.89,
"detailed_temperature": {
"min": 14.89,
"max": 27.33,
"morning": 14.89,
"day": 24.89,
"evening": 27.33,
"night": 23.1
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 7
}
}
]
}
},
{
"city_data": {
"lat": 25.774173736572266,
"lon": -80.19361877441406,
"city_name": "Miami"
},
"weather_data": {
"current_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 20.14,
"detailed_temperature": {
"min": 20.14,
"max": 30.88,
"morning": 20.83,
"day": 30.88,
"evening": 26.41,
"night": 23.2
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 11.85
},
"forecast_data": [
{
"day_name": "d0",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 30.88,
"detailed_temperature": {
"min": 20.14,
"max": 30.88,
"morning": 20.83,
"day": 30.88,
"evening": 26.41,
"night": 23.2
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 11.85
}
},
{
"day_name": "d1",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 28.94,
"detailed_temperature": {
"min": 21.56,
"max": 29.68,
"morning": 21.61,
"day": 28.94,
"evening": 26.24,
"night": 23.22
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 11.62
}
},
{
"day_name": "d2",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 29.32,
"detailed_temperature": {
"min": 21.09,
"max": 31.17,
"morning": 21.09,
"day": 29.32,
"evening": 26.99,
"night": 23.65
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 11.67
}
},
{
"day_name": "d3",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 27.11,
"detailed_temperature": {
"min": 21.26,
"max": 30.37,
"morning": 21.26,
"day": 27.11,
"evening": 28.03,
"night": 22.17
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 11.53
}
},
{
"day_name": "d4",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 25.7,
"detailed_temperature": {
"min": 19.67,
"max": 28.59,
"morning": 19.67,
"day": 25.7,
"evening": 27.08,
"night": 20.91
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 12
}
},
{
"day_name": "d5",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 25.27,
"detailed_temperature": {
"min": 18.6,
"max": 27.95,
"morning": 18.6,
"day": 25.27,
"evening": 25.42,
"night": 20
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 12
}
},
{
"day_name": "d6",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 26.84,
"detailed_temperature": {
"min": 19.13,
"max": 28.99,
"morning": 19.13,
"day": 26.84,
"evening": 27.56,
"night": 21.58
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 12
}
},
{
"day_name": "d7",
"weather_data": {
"condition": "Sunny",
"description": "clear sky",
"current_temperature": 28.99,
"detailed_temperature": {
"min": 20.87,
"max": 30.64,
"morning": 20.87,
"day": 28.99,
"evening": 29.22,
"night": 22.64
},
"precipitation": {
"probability": 0,
"rain_volume": 0,
"snow_volume": 0
},
"uv_index": 12
}
}
]
}
}
]

View file

@ -0,0 +1,93 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use chrono::{Duration, Utc};
use crate::weather::utils::*;
use crate::weather::weathercontroller::{
CityData, CityWeatherData, GeoLocationData, WeatherController,
};
pub struct DummyWeatherController {
city_weather_data: Vec<CityWeatherData>,
}
impl DummyWeatherController {
pub fn new() -> Self {
Self { city_weather_data: vec![] }
}
fn generate_dummy_data() -> Vec<CityWeatherData> {
let json_data = std::include_str!("./dummyweather.json");
match serde_json::from_str::<Vec<CityWeatherData>>(json_data) {
Ok(weather_data) => {
// fix day names
let mut weather_data = weather_data.clone();
for city_data in &mut weather_data {
let forecast_data = &mut (city_data.weather_data.forecast_data);
for (index, data) in forecast_data.iter_mut().enumerate() {
if index == 0 {
data.day_name = "Today".into();
} else {
data.day_name =
get_day_from_datetime(Utc::now() + Duration::days(index as i64));
}
}
}
return weather_data;
}
Err(e) => {
log::warn!("Cannot read dummy weather data! Error: {}", e);
}
}
vec![]
}
}
impl WeatherController for DummyWeatherController {
fn load(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.city_weather_data = Self::generate_dummy_data();
Ok(())
}
fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn refresh_cities(&mut self) -> Result<Vec<CityWeatherData>, Box<dyn std::error::Error>> {
Ok(self.city_weather_data.clone())
}
fn add_city(
&mut self,
_city: CityData,
) -> Result<Option<CityWeatherData>, Box<dyn std::error::Error>> {
// not supported for the dummy data
unimplemented!();
}
fn reorder_cities(
&mut self,
index: usize,
new_index: usize,
) -> Result<(), Box<dyn std::error::Error>> {
self.city_weather_data.swap(index, new_index);
Ok(())
}
fn remove_city(&mut self, index: usize) -> Result<(), Box<dyn std::error::Error>> {
self.city_weather_data.remove(index);
Ok(())
}
fn search_location(
&self,
_query: String,
) -> Result<Vec<GeoLocationData>, Box<dyn std::error::Error>> {
// not supported for the dummy data
unimplemented!();
}
}

View file

@ -0,0 +1,21 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
mod weathercontroller;
mod weatherdisplaycontroller;
mod dummyweathercontroller;
pub use weathercontroller::WeatherControllerPointer;
pub use weathercontroller::WeatherControllerSharedPointer;
pub use weatherdisplaycontroller::WeatherDisplayController;
pub use dummyweathercontroller::DummyWeatherController;
#[cfg(not(target_arch = "wasm32"))]
mod openweathercontroller;
#[cfg(not(target_arch = "wasm32"))]
pub use openweathercontroller::OpenWeatherController;
pub mod utils;

View file

@ -0,0 +1,417 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
#![cfg(not(target_arch = "wasm32"))]
use chrono::DateTime;
use openweather_sdk::responses::{GeocodingResponse, OneCallResponse};
use openweather_sdk::{Language, OpenWeather, Units};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io;
use std::io::{BufReader, BufWriter, Write};
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::Arc;
use std::vec;
use tokio::sync::Mutex;
use crate::weather::utils::*;
use crate::weather::weathercontroller::{
CityData, CityWeatherData, DayWeatherData, ForecastWeatherData, GeoLocationData,
PrecipitationData, TemperatureData, WeatherCondition, WeatherController, WeatherData,
};
#[cfg(target_os = "android")]
use std::env;
const CITIES_STORED_FILE_NAME: &str = "cities_data.json";
const ORGANIZATION_QUALIFIER: &str = "dev"; // have to match android app name in cargo.toml
const ORGANIZATION_NAME: &str = "slint.examples"; // have to match android app name in cargo.toml
const APPLICATION_NAME: &str = "weatherdemo"; // have to match app android name in cargo.toml
fn project_data_dir() -> Option<PathBuf> {
#[cfg(target_os = "android")]
{
match env::var("ANDROID_DATA") {
Ok(data_root) => {
if data_root.is_empty() {
return None;
} else {
let project_name = format!(
"{}.{}.{}",
ORGANIZATION_QUALIFIER, ORGANIZATION_NAME, APPLICATION_NAME
);
return Some(PathBuf::from(format!(
"{}/data/{}/files",
data_root, project_name
)));
}
}
Err(_e) => {
log::warn!("Cannot read ANDROID_DATA, persistence not avaialble.");
return None;
}
}
}
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
{
if let Some(project_dir) = directories::ProjectDirs::from(
ORGANIZATION_QUALIFIER,
ORGANIZATION_NAME,
APPLICATION_NAME,
) {
return Some(project_dir.data_dir().to_path_buf());
};
None
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct WeatherClient {
pub city_data: CityData,
pub weather_data: Option<OneCallResponse>,
}
pub struct OpenWeatherController {
tokio_runtime: tokio::runtime::Runtime,
weather_api: OpenWeather,
city_clients: Arc<Mutex<Vec<WeatherClient>>>,
storage_path: Option<PathBuf>,
}
impl OpenWeatherController {
pub fn new(api_key: String) -> Self {
let mut weather_api = OpenWeather::new(api_key, Units::Metric, Language::English);
weather_api.one_call.fields.minutely = false;
weather_api.one_call.fields.hourly = false;
weather_api.one_call.fields.alerts = false;
let storage_path;
if let Some(project_dir) = project_data_dir() {
storage_path = Some(project_dir.as_path().join(CITIES_STORED_FILE_NAME));
} else {
storage_path = None;
log::error!("Failed to initialize project dir. Persistent data will not be loaded");
}
Self {
tokio_runtime: tokio::runtime::Runtime::new().unwrap(),
weather_api,
city_clients: Arc::new(Mutex::new(vec![])),
storage_path,
}
}
fn weather_condition_from_icon_icon_type(icon_type: &str) -> WeatherCondition {
match icon_type {
"01d" | "01n" => WeatherCondition::Sunny,
"02d" | "02n" => WeatherCondition::PartiallyCloudy,
"03d" | "03n" => WeatherCondition::MostlyCloudy,
"04d" | "04n" => WeatherCondition::Cloudy,
"10d" | "10n" => WeatherCondition::SunnyRainy,
"09d" | "09n" => WeatherCondition::Rainy,
"11d" | "11n" => WeatherCondition::Stormy,
"13d" | "13n" => WeatherCondition::Snowy,
"50d" | "50n" => WeatherCondition::Foggy,
_ => WeatherCondition::Unknown,
}
}
fn current_day_weather_data_from_response(
weather_response: &Option<OneCallResponse>,
) -> DayWeatherData {
if let Some(weather_data) = weather_response {
if let Some(current) = &weather_data.current {
let weather_details = &current.weather[0];
let today_weather_info =
weather_data.daily.as_ref().and_then(|daily| daily.first());
let detailed_temp = match today_weather_info {
Some(info) => {
let temp = info.temp;
TemperatureData {
min: temp.min,
max: temp.max,
morning: temp.morn,
day: temp.day,
evening: temp.eve,
night: temp.night,
}
}
None => TemperatureData {
min: current.temp,
max: current.temp,
morning: current.temp,
day: current.temp,
evening: current.temp,
night: current.temp,
},
};
return DayWeatherData {
description: weather_details.description.clone(),
condition: Self::weather_condition_from_icon_icon_type(&weather_details.icon),
current_temperature: current.temp,
detailed_temperature: detailed_temp,
precipitation: PrecipitationData::default(),
uv_index: 0.0,
};
}
}
DayWeatherData::default()
}
fn forecast_day_weather_data_from_response(
weather_response: &Option<OneCallResponse>,
) -> Vec<ForecastWeatherData> {
let mut forecast_weather_info: Vec<ForecastWeatherData> = vec![];
if let Some(weather_data) = weather_response {
if let Some(daily_weather_data) = &weather_data.daily {
for day_weather_data in daily_weather_data.iter() {
if let Some(datetime) = DateTime::from_timestamp(day_weather_data.datetime, 0) {
let weather_details = &day_weather_data.weather[0];
let detailed_temperature = TemperatureData {
min: day_weather_data.temp.min,
max: day_weather_data.temp.max,
morning: day_weather_data.temp.morn,
day: day_weather_data.temp.day,
evening: day_weather_data.temp.eve,
night: day_weather_data.temp.night,
};
let precipitation: PrecipitationData = PrecipitationData {
probability: day_weather_data.pop,
rain_volume: day_weather_data.rain.unwrap_or(0 as f64),
snow_volume: day_weather_data.snow.unwrap_or(0 as f64),
};
let day_weather_info = DayWeatherData {
description: weather_details.description.clone(),
condition: Self::weather_condition_from_icon_icon_type(
&weather_details.icon,
),
current_temperature: day_weather_data.temp.day,
detailed_temperature,
precipitation,
uv_index: day_weather_data.uvi,
};
// TODO: localization
forecast_weather_info.push(ForecastWeatherData {
day_name: get_day_from_datetime(datetime),
weather_data: day_weather_info,
});
}
}
}
}
forecast_weather_info
}
fn city_weather_data_from_client(city_client: &WeatherClient) -> CityWeatherData {
let current_data = Self::current_day_weather_data_from_response(&city_client.weather_data);
let forecast_data =
Self::forecast_day_weather_data_from_response(&city_client.weather_data);
CityWeatherData {
city_data: city_client.city_data.clone(),
weather_data: WeatherData { current_data, forecast_data },
}
}
fn geo_location_data_from_response(response: &GeocodingResponse) -> GeoLocationData {
GeoLocationData {
name: response.name.clone(),
state: response.state.clone(),
country: response.country.clone(),
lat: response.lat,
lon: response.lon,
}
}
}
impl WeatherController for OpenWeatherController {
fn load(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(storage_path) = &self.storage_path {
log::debug!("Loading data from: {:?}", storage_path.to_str());
let file = File::open(storage_path.as_path())?;
let reader = BufReader::new(file);
let city_clients_data: Vec<WeatherClient> = serde_json::from_reader(reader)?;
log::debug!("Successfully loaded {} cities", city_clients_data.len());
let city_clients = self.city_clients.clone();
self.tokio_runtime.block_on(async move {
let mut city_clients = city_clients.lock().await;
*city_clients = city_clients_data;
Ok(())
})
} else {
Err(Box::new(io::Error::new(io::ErrorKind::NotFound, "Storage path not initialized")))
}
}
fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(storage_path) = &self.storage_path {
log::debug!("Saving data to: {:?}", storage_path.to_str());
let file = File::create(storage_path)?;
let mut writer = BufWriter::new(file);
let city_clients = self.city_clients.clone();
self.tokio_runtime.block_on(async move {
let city_clients = city_clients.lock().await;
serde_json::to_writer(&mut writer, city_clients.deref())?;
writer.flush()?;
Ok(())
})
} else {
Err(Box::new(io::Error::new(io::ErrorKind::NotFound, "Storage path not initialized")))
}
}
fn refresh_cities(&mut self) -> Result<Vec<CityWeatherData>, Box<dyn std::error::Error>> {
log::debug!("Refreshing all the clients!");
let city_clients_clone = self.city_clients.clone();
let weather_api = self.weather_api.clone();
self.tokio_runtime.block_on(async move {
let mut city_clients = city_clients_clone.lock().await;
let mut errors = vec![];
for client in city_clients.iter_mut() {
// TODO: Spawn all tasks at once and join them later.
if let Err(e) = client.refresh_weather(&weather_api).await {
errors.push(e);
}
}
log::debug!("Refreshing weather finished!");
if !errors.is_empty() && errors.len() == city_clients.len() {
return Err(errors.pop().unwrap());
}
Ok(city_clients.iter().map(Self::city_weather_data_from_client).collect())
})
}
fn add_city(
&mut self,
city: CityData,
) -> Result<Option<CityWeatherData>, Box<dyn std::error::Error>> {
log::debug!("Adding new city: {city:?}");
let city_clients_clone = self.city_clients.clone();
let weather_api = self.weather_api.clone();
self.tokio_runtime.block_on(async move {
let mut city_clients = city_clients_clone.lock().await;
match city_clients.iter().position(|client| client.city_data == city) {
Some(_) => {
log::info!("City already present in list!");
Ok(None)
}
None => {
// Add to list and refresh
let mut client = WeatherClient::new(city.lat, city.lon, &city.city_name);
client.refresh_weather(&weather_api).await?;
let city_weather_data = Self::city_weather_data_from_client(&client);
city_clients.push(client);
Ok(Some(city_weather_data))
}
}
})
}
fn reorder_cities(
&mut self,
index: usize,
new_index: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let city_clients = self.city_clients.clone();
self.tokio_runtime.block_on(async move {
let mut city_clients = city_clients.lock().await;
city_clients.swap(index, new_index);
});
Ok(())
}
fn remove_city(&mut self, index: usize) -> Result<(), Box<dyn std::error::Error>> {
let city_clients = self.city_clients.clone();
self.tokio_runtime.block_on(async move {
let mut city_clients = city_clients.lock().await;
city_clients.remove(index);
});
Ok(())
}
fn search_location(
&self,
query: String,
) -> Result<Vec<GeoLocationData>, Box<dyn std::error::Error>> {
log::debug!("Searching for: {query}");
let weather_api = self.weather_api.clone();
if query.is_empty() {
return Ok(vec![]);
}
self.tokio_runtime.block_on(async move {
let response_data = weather_api.geocoding.get_geocoding(&query, None, None, 0).await?;
log::debug!("Search result: {response_data:?}");
let mut unique_response_data: Vec<GeocodingResponse> = Vec::new();
for element in response_data {
if !unique_response_data.iter().any(|existing_element| {
if existing_element.name == element.name
&& existing_element.country == element.country
&& existing_element.state == element.state
{
return true;
}
false
}) {
unique_response_data.push(element);
}
}
Ok(unique_response_data.iter().map(Self::geo_location_data_from_response).collect())
})
}
}
impl WeatherClient {
pub fn new(lat: f64, lon: f64, cname: &str) -> Self {
Self { city_data: CityData { lat, lon, city_name: cname.to_string() }, weather_data: None }
}
pub async fn refresh_weather(
&mut self,
weather_api: &OpenWeather,
) -> Result<(), Box<dyn std::error::Error>> {
let res = weather_api.one_call.call(self.city_data.lat, self.city_data.lon).await;
log::debug!("Weather response: {res:?}");
match res {
Ok(response_data) => {
self.weather_data = Some(response_data);
log::debug!("Response received at: {:?}", chrono::offset::Local::now().timestamp());
Ok(())
}
Err(e) => Err(e),
}
}
}

View file

@ -0,0 +1,12 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use chrono::{DateTime, Datelike};
pub fn get_day_from_datetime(date: DateTime<chrono::offset::Utc>) -> String {
if date.day() == chrono::offset::Local::now().day() {
// TODO: translations
return "Today".to_string();
}
date.weekday().to_string()
}

View file

@ -0,0 +1,115 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct CityData {
pub lat: f64,
pub lon: f64,
pub city_name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub enum WeatherCondition {
#[default]
Unknown,
Sunny,
PartiallyCloudy,
MostlyCloudy,
Cloudy,
SunnyRainy,
Rainy,
Stormy,
Snowy,
Foggy,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct TemperatureData {
pub min: f64,
pub max: f64,
pub morning: f64,
pub day: f64,
pub evening: f64,
pub night: f64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct PrecipitationData {
pub probability: f64,
pub rain_volume: f64,
pub snow_volume: f64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct DayWeatherData {
pub condition: WeatherCondition,
pub description: String,
pub current_temperature: f64,
pub detailed_temperature: TemperatureData,
pub precipitation: PrecipitationData,
pub uv_index: f64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct ForecastWeatherData {
pub day_name: String,
pub weather_data: DayWeatherData,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct WeatherData {
pub current_data: DayWeatherData,
pub forecast_data: Vec<ForecastWeatherData>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct CityWeatherData {
pub city_data: CityData,
pub weather_data: WeatherData,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GeoLocationData {
pub name: String,
pub lat: f64,
pub lon: f64,
pub country: String,
pub state: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
pub type WeatherControllerPointer = Box<dyn WeatherController + Send>;
#[cfg(target_arch = "wasm32")]
pub type WeatherControllerPointer = Box<dyn WeatherController + Send + 'static>;
pub type WeatherControllerSharedPointer = Arc<Mutex<WeatherControllerPointer>>;
pub trait WeatherController {
fn load(&mut self) -> Result<(), Box<dyn std::error::Error>>;
fn save(&self) -> Result<(), Box<dyn std::error::Error>>;
fn refresh_cities(&mut self) -> Result<Vec<CityWeatherData>, Box<dyn std::error::Error>>;
fn add_city(
&mut self,
city: CityData,
) -> Result<Option<CityWeatherData>, Box<dyn std::error::Error>>;
fn reorder_cities(
&mut self,
index: usize,
new_index: usize,
) -> Result<(), Box<dyn std::error::Error>>;
fn remove_city(&mut self, index: usize) -> Result<(), Box<dyn std::error::Error>>;
fn search_location(
&self,
query: String,
) -> Result<Vec<GeoLocationData>, Box<dyn std::error::Error>>;
}

View file

@ -0,0 +1,472 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel, Weak};
use std::rc::Rc;
use crate::ui;
use ui::{
AppWindow, BusyLayerController, CityWeather, CityWeatherInfo, GeoLocation, GeoLocationEntry,
IconType, TemperatureInfo, WeatherForecastInfo, WeatherInfo,
};
use crate::weather::weathercontroller::{
CityData, CityWeatherData, DayWeatherData, ForecastWeatherData, GeoLocationData,
WeatherCondition, WeatherControllerSharedPointer,
};
#[cfg(not(target_arch = "wasm32"))]
use async_std::task::spawn as spawn_task;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local as spawn_task;
pub struct WeatherDisplayController {
data_controller: WeatherControllerSharedPointer,
}
fn forecast_graph_command(
model: ModelRc<WeatherForecastInfo>,
days_count: i32,
width: f32,
height: f32,
) -> SharedString {
if days_count == 0 || width == 0.0 || height == 0.0 {
return SharedString::new();
}
let temperatures: Vec<f32> = model
.clone()
.iter()
.take(days_count as usize)
.map(|info| info.weather_info.detailed_temp.day)
.collect();
const MIN_MAX_MARGIN: f32 = 5.0;
let min_temperature = match temperatures.iter().min_by(|a, b| a.total_cmp(b)) {
Some(min) => min - MIN_MAX_MARGIN,
None => 0.0,
};
let max_temperature = match temperatures.iter().max_by(|a, b| a.total_cmp(b)) {
Some(max) => max + MIN_MAX_MARGIN,
None => 50.0,
};
let max_temperature_value = max_temperature - min_temperature;
let temperature_ratio = height / max_temperature_value;
let day_width = width / days_count as f32;
let max_day_shift = days_count as f32 * day_width;
let border_command =
format!(
"M 0 0 M {max_width} 0 M {max_width} {max_temperature_value} M 0 {max_temperature_value} ",
max_width=max_day_shift, max_temperature_value=max_temperature_value * temperature_ratio);
let mut command = border_command;
let day_shift = |index: f32| -> f32 { index * day_width + 0.5 * day_width };
let day_temperature =
|temperature: f32| -> f32 { (max_temperature - temperature) * temperature_ratio };
for (index, &temperature) in temperatures.iter().enumerate() {
if index == 0 {
command += format!(
"M {x} {y} ",
x = day_shift(index as f32),
y = day_temperature(temperature)
)
.as_str();
}
if let Some(next_temperature) = temperatures.get(index + 1) {
let next_temperature = *next_temperature;
let day1 = day_shift(index as f32);
let day2 = day_shift(index as f32 + 1.0);
let temp1 = day_temperature(temperature);
let temp2 = day_temperature(next_temperature);
let day_mid = (day1 + day2) / 2.0;
let temp_mid = (temp1 + temp2) / 2.0;
let cp_day1 = (day_mid + day1) / 2.0;
let cp_day2 = (day_mid + day2) / 2.0;
command += format!(
"Q {x1} {y1} {cx1} {cy1} Q {x2} {y2} {cx2} {cy2} ",
x1 = cp_day1,
y1 = temp1,
cx1 = day_mid,
cy1 = temp_mid,
x2 = cp_day2,
y2 = temp2,
cx2 = day2,
cy2 = temp2
)
.as_str();
}
}
SharedString::from(command)
}
impl WeatherDisplayController {
pub fn new(data_controller: &WeatherControllerSharedPointer) -> Self {
Self { data_controller: data_controller.clone() }
}
pub fn initialize_ui(&self, window: &AppWindow, support_add_city: bool) {
let city_weather = window.global::<CityWeather>();
let geo_location = window.global::<GeoLocation>();
// initialized models
city_weather
.set_city_weather(ModelRc::from(Rc::new(VecModel::<CityWeatherInfo>::from(vec![]))));
geo_location
.set_result_list(ModelRc::from(Rc::new(VecModel::<GeoLocationEntry>::from(vec![]))));
// initialize state
city_weather.set_can_add_city(support_add_city);
// handle callbacks
city_weather.on_get_forecast_graph_command(forecast_graph_command);
city_weather.on_refresh_all({
let window_weak = window.as_weak();
let data_controller = self.data_controller.clone();
move || Self::refresh_cities(&window_weak, &data_controller)
});
city_weather.on_reorder({
let window_weak = window.as_weak();
let data_controller = self.data_controller.clone();
move |index, new_index| {
if let Err(e) =
Self::reorder_cities(&window_weak, &data_controller, index, new_index)
{
log::warn!("Failed to reorder city from {} to {}: {}", index, new_index, e);
}
}
});
city_weather.on_delete({
let window_weak = window.as_weak();
let data_controller = self.data_controller.clone();
move |index| {
if let Err(e) = Self::remove_city(&window_weak, &data_controller, index) {
log::warn!("Failed to remove city from {}: {}", index, e);
}
}
});
geo_location.on_search_location({
let window_weak = window.as_weak();
let data_controller = self.data_controller.clone();
move |location| Self::search_location(&window_weak, &data_controller, location)
});
geo_location.on_add_location({
let window_weak = window.as_weak();
let data_controller = self.data_controller.clone();
move |location| {
Self::add_city(&window_weak, &data_controller, location);
}
});
}
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn refresh(&self, window: &AppWindow) {
Self::set_busy(window);
let window_weak = window.as_weak();
Self::refresh_cities(&window_weak, &self.data_controller);
}
pub fn load(&self, window: &AppWindow) {
Self::set_busy(window);
let window_weak = window.as_weak();
let data_controller = self.data_controller.clone();
spawn_task(async move {
let city_data_res = async {
let mut data_controller = data_controller.lock().unwrap();
data_controller.load()?;
data_controller.refresh_cities()
}
.await;
let city_data = match city_data_res {
Ok(city_data) => Some(city_data),
Err(e) => {
log::warn!("Failed to load cities: {}.", e);
None
}
};
Self::check_update_error(window_weak.upgrade_in_event_loop(move |window| {
if let Some(city_data) = city_data {
WeatherDisplayController::update_displayed_cities(&window, city_data);
}
Self::unset_busy(&window);
}));
});
}
fn refresh_cities(
window_weak: &Weak<AppWindow>,
data_controller: &WeatherControllerSharedPointer,
) {
let window_weak = window_weak.clone();
let data_controller = data_controller.clone();
spawn_task(async move {
let city_data_res = async { data_controller.lock().unwrap().refresh_cities() }.await;
let city_data = match city_data_res {
Ok(city_data) => Some(city_data),
Err(e) => {
log::warn!("Failed to update cities: {}.", e);
None
}
};
Self::check_update_error(window_weak.upgrade_in_event_loop(move |window| {
if let Some(city_data) = city_data {
WeatherDisplayController::update_displayed_cities(&window, city_data);
}
Self::unset_busy(&window);
}));
});
}
fn add_city(
window_weak: &Weak<AppWindow>,
data_controller: &WeatherControllerSharedPointer,
location: GeoLocationEntry,
) {
let city = CityData {
lat: location.lat as f64,
lon: location.lon as f64,
city_name: String::from(&location.name),
};
let city_data_res = data_controller.lock().unwrap().add_city(city);
// update ui
let window = window_weak.upgrade().unwrap();
match city_data_res {
Ok(city_data) => {
if let Some(city_data) = city_data {
let city_weather = window.global::<CityWeather>();
let city_weather_list = city_weather.get_city_weather();
let city_weather = Self::city_weather_info_from_data(&city_data);
city_weather_list
.as_any()
.downcast_ref::<slint::VecModel<CityWeatherInfo>>()
.unwrap()
.push(city_weather);
}
}
Err(e) => {
log::warn!("Failed to add city: {}.", e);
}
}
Self::unset_busy(&window);
}
fn reorder_cities(
window_weak: &Weak<AppWindow>,
data_controller: &WeatherControllerSharedPointer,
index: i32,
new_index: i32,
) -> Result<(), Box<dyn std::error::Error>> {
let pos: usize = index.try_into()?;
let new_pos: usize = new_index.try_into()?;
data_controller.lock().unwrap().reorder_cities(pos, new_pos)?;
// update ui
let window = window_weak.upgrade().unwrap();
let city_weather = window.global::<CityWeather>();
let city_weather_list = city_weather.get_city_weather();
let pos_data = city_weather_list.row_data(pos).ok_or(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Index out of bounds",
)))?;
let new_pos_data = city_weather_list.row_data(new_pos).ok_or(Box::new(
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Index out of bounds"),
))?;
city_weather_list.set_row_data(pos, new_pos_data);
city_weather_list.set_row_data(new_pos, pos_data);
Ok(())
}
fn remove_city(
window_weak: &Weak<AppWindow>,
data_controller: &WeatherControllerSharedPointer,
index: i32,
) -> Result<(), Box<dyn std::error::Error>> {
let pos: usize = index.try_into()?;
data_controller.lock().unwrap().remove_city(pos)?;
// update ui
let window = window_weak.upgrade().unwrap();
let city_weather = window.global::<CityWeather>();
let city_weather_list = city_weather.get_city_weather();
let model = city_weather_list
.as_any()
.downcast_ref::<slint::VecModel<CityWeatherInfo>>()
.expect("CityWeatherInfo model is not provided!");
model.remove(pos);
Ok(())
}
fn search_location(
window_weak: &Weak<AppWindow>,
data_controller: &WeatherControllerSharedPointer,
query: slint::SharedString,
) {
let window_weak = window_weak.clone();
let data_controller = data_controller.clone();
let query = query.to_string();
spawn_task(async move {
let locations_res =
async { data_controller.lock().unwrap().search_location(query) }.await;
let locations = match locations_res {
Ok(locations) => Some(locations),
Err(e) => {
log::warn!("Failed to search for location: {}.", e);
None
}
};
Self::check_update_error(window_weak.upgrade_in_event_loop(move |window| {
if let Some(locations) = locations {
WeatherDisplayController::update_location_search_results(&window, locations);
}
}));
});
}
fn update_displayed_cities(window: &AppWindow, data: Vec<CityWeatherData>) {
let display_vector: Vec<CityWeatherInfo> =
data.iter().map(Self::city_weather_info_from_data).collect();
let city_weather = window.global::<CityWeather>().get_city_weather();
let model = city_weather
.as_any()
.downcast_ref::<VecModel<CityWeatherInfo>>()
.expect("City weather model not set.");
model.set_vec(display_vector);
}
fn update_location_search_results(window: &AppWindow, result: Vec<GeoLocationData>) {
let display_vector: Vec<GeoLocationEntry> =
result.iter().map(Self::geo_location_entry_from_data).collect();
let geo_location = window.global::<GeoLocation>().get_result_list();
let model = geo_location
.as_any()
.downcast_ref::<VecModel<GeoLocationEntry>>()
.expect("Geo location entry model not set.");
model.set_vec(display_vector);
}
fn set_busy(window: &AppWindow) {
window.global::<BusyLayerController>().invoke_set_busy();
}
fn unset_busy(window: &AppWindow) {
window.global::<BusyLayerController>().invoke_unset_busy();
}
fn check_update_error<E: std::fmt::Display>(result: Result<(), E>) {
if let Err(e) = result {
log::error!("Error while updating UI: {}", e);
}
}
fn icon_type_from_condition(condition: &WeatherCondition) -> IconType {
match condition {
WeatherCondition::Sunny => IconType::Sunny,
WeatherCondition::PartiallyCloudy => IconType::PartiallyCloudy,
WeatherCondition::MostlyCloudy => IconType::MostlyCloudy,
WeatherCondition::Cloudy => IconType::Cloudy,
WeatherCondition::SunnyRainy => IconType::SunnyRainy,
WeatherCondition::Rainy => IconType::Rainy,
WeatherCondition::Stormy => IconType::Stormy,
WeatherCondition::Snowy => IconType::Snowy,
WeatherCondition::Foggy => IconType::Foggy,
_ => IconType::Unknown,
}
}
fn weather_info_from_data(data: &DayWeatherData) -> WeatherInfo {
WeatherInfo {
description: SharedString::from(&data.description),
icon_type: Self::icon_type_from_condition(&data.condition),
current_temp: data.current_temperature as f32,
detailed_temp: TemperatureInfo {
min: data.detailed_temperature.min as f32,
max: data.detailed_temperature.max as f32,
morning: data.detailed_temperature.morning as f32,
day: data.detailed_temperature.day as f32,
evening: data.detailed_temperature.evening as f32,
night: data.detailed_temperature.night as f32,
},
uv: data.uv_index as i32,
precipitation_prob: data.precipitation.probability as f32,
rain: data.precipitation.rain_volume as f32,
snow: data.precipitation.snow_volume as f32,
}
}
fn forecast_weather_info_from_data(data: &[ForecastWeatherData]) -> Vec<WeatherForecastInfo> {
data.iter()
.map(|forecast_data| WeatherForecastInfo {
day_name: SharedString::from(&forecast_data.day_name),
weather_info: Self::weather_info_from_data(&forecast_data.weather_data),
})
.collect()
}
fn city_weather_info_from_data(data: &CityWeatherData) -> CityWeatherInfo {
let current_weather_info = Self::weather_info_from_data(&data.weather_data.current_data);
let forecast_weather_info =
Self::forecast_weather_info_from_data(&data.weather_data.forecast_data);
CityWeatherInfo {
city_name: SharedString::from(&data.city_data.city_name),
current_weather: current_weather_info,
forecast_weather: Rc::new(slint::VecModel::from(forecast_weather_info)).into(),
}
}
fn geo_location_entry_from_data(data: &GeoLocationData) -> GeoLocationEntry {
GeoLocationEntry {
name: SharedString::from(&data.name),
state: SharedString::from(data.state.as_deref().unwrap_or_default()),
country: SharedString::from(&data.country),
lat: data.lat as f32,
lon: data.lon as f32,
}
}
}

View file

@ -0,0 +1,90 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { WindowInfo } from "./ui_utils.slint";
import { AppText } from "./controls/generic.slint";
import { AboutSlint } from "std-widgets.slint";
component AboutFelgo {
width: 100%;
height: 100%;
VerticalLayout {
spacing: 5px;
width: 90% * parent.width;
padding-bottom: 14px;
Rectangle {
background: white;
border-radius: self.height / 2;
preferred-height: self.width * 45%;
logo-layout := VerticalLayout {
alignment: center;
spacing: 2px;
made-text := AppText {
text: "MADE BY";
horizontal-alignment: center;
font-size: 1rem;
color: black;
}
Rectangle {
Image {
image-fit: contain;
width: 70% * parent.width;
source: @image-url("./assets/felgo-logo.svg");
}
}
}
}
AppText {
text: "https://felgo.com/";
horizontal-alignment: center;
}
}
}
export component AboutBox {
VerticalLayout {
if WindowInfo.is-portrait: VerticalLayout {
alignment: center;
Rectangle {
AboutFelgo {
width: Math.min(200px, 80% * parent.width);
min-height: self.preferred-height;
}
}
Rectangle {
AboutSlint {
width: Math.min(200px, 80% * parent.width);
min-height: self.preferred-height;
}
}
}
if !WindowInfo.is-portrait: HorizontalLayout {
alignment: space-around;
// adjust AboutFelgo size to look the same as AboutSlint
Rectangle {
max-width: 200px;
AboutFelgo {
height: 84%;
preferred-width: 200px;
padding-bottom: 10px;
}
}
AboutSlint {
max-width: 200px;
}
}
}
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 3172 635" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="Logo-Vermillion-87-Saturation" serif:id="Logo Vermillion 87 Saturation" transform="matrix(1,0,0,1,-387.622,-203.331)">
<path id="segmented-line" serif:id="segmented line" d="M674.5,574.796L743.796,505.5L1044.3,806L905.704,806L674.5,574.796ZM605.204,505.5L427.342,327.639C408.219,308.516 408.219,277.465 427.342,258.342C446.465,239.219 477.516,239.219 496.639,258.342L674.5,436.204L605.204,505.5Z" style="fill:rgb(167,43,12);"/>
<g id="whole-line" serif:id="whole line" transform="matrix(0.699964,0.699964,-1.22244,1.22244,1092.04,-519.105)">
<path d="M511,331.651L412,388.339L412,779.656C412,795.3 434.18,808 461.5,808C488.82,808 511,795.3 511,779.656L511,331.651Z" style="fill:rgb(240,78,38);"/>
</g>
<g id="vertical" transform="matrix(0.989899,0,0,1,5.16162,-2)">
<path d="M511,295C511,267.956 488.82,246 461.5,246C434.18,246 412,267.956 412,295L412,759C412,786.044 434.18,808 461.5,808C488.82,808 511,786.044 511,759L511,295Z" style="fill:rgb(240,78,38);"/>
</g>
</g>
<g transform="matrix(1.02897,0,0,1.02897,30.2111,266.834)">
<path d="M1080.69,-95.336L1080.69,-210.913L761.725,-210.913L761.725,314.437L881.805,314.437L881.805,121.558L1076.93,121.558L1076.93,5.981L881.805,5.981L881.805,-95.336L1080.69,-95.336Z" style="fill:rgb(34,38,46);fill-rule:nonzero;"/>
<g transform="matrix(1,0,0,1,5.8311,0)">
<path d="M1272.07,198.86L1272.07,105.798L1459.69,105.798L1459.69,-8.278L1272.07,-8.278L1272.07,-95.336L1478.45,-95.336L1478.45,-210.913L1151.99,-210.913L1151.99,314.437L1482.2,314.437L1482.2,198.86L1272.07,198.86Z" style="fill:rgb(34,38,46);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,9.71849,0)">
<path d="M1669.83,198.86L1669.83,-210.913L1549.75,-210.913L1549.75,314.437L1857.45,314.437L1857.45,198.86L1669.83,198.86Z" style="fill:rgb(34,38,46);fill-rule:nonzero;"/>
</g>
<path d="M2430.09,21.742L2166.66,21.742L2166.66,126.812L2307,126.812C2288.24,177.095 2242.46,207.866 2172.66,207.866C2070.6,207.866 2007.56,142.572 2007.56,53.263C2007.56,-39.049 2073.6,-104.342 2162.16,-104.342C2221.45,-104.342 2268.73,-77.324 2290.49,-42.801L2392.56,-101.34C2348.28,-171.887 2263.47,-221.42 2162.91,-221.42C2008.31,-221.42 1887.48,-99.839 1887.48,52.512C1887.48,203.363 2006.05,324.944 2171.16,324.944C2318.26,324.944 2430.09,227.379 2430.09,66.772L2430.09,21.742Z" style="fill:rgb(34,38,46);fill-rule:nonzero;"/>
<path d="M2748.3,324.944C2899.15,324.944 3021.48,206.365 3021.48,51.762C3021.48,-102.841 2899.15,-221.42 2748.3,-221.42C2597.45,-221.42 2475.12,-102.841 2475.12,51.762C2475.12,206.365 2597.45,324.944 2748.3,324.944ZM2748.3,207.866C2662.74,207.866 2595.2,145.574 2595.2,51.762C2595.2,-42.051 2662.74,-104.342 2748.3,-104.342C2833.86,-104.342 2901.4,-42.051 2901.4,51.762C2901.4,145.574 2833.86,207.866 2748.3,207.866Z" style="fill:rgb(34,38,46);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/></svg>

After

Width:  |  Height:  |  Size: 400 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M233.4 105.4c12.5-12.5 32.8-12.5 45.3 0l192 192c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L256 173.3 86.6 342.6c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l192-192z"/></svg>

After

Width:  |  Height:  |  Size: 400 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M416 208H272V64c0-17.7-14.3-32-32-32h-32c-17.7 0-32 14.3-32 32v144H32c-17.7 0-32 14.3-32 32v32c0 17.7 14.3 32 32 32h144v144c0 17.7 14.3 32 32 32h32c17.7 0 32-14.3 32-32V304h144c17.7 0 32-14.3 32-32v-32c0-17.7-14.3-32-32-32z"/></svg>

After

Width:  |  Height:  |  Size: 455 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256.5 8c66.3 .1 126.4 26.2 170.9 68.7l35.7-35.7C478.1 25.9 504 36.6 504 57.9V192c0 13.3-10.7 24-24 24H345.9c-21.4 0-32.1-25.9-17-41l41.8-41.8c-30.9-28.9-70.8-44.9-113.2-45.3-92.4-.8-170.3 74-169.5 169.4C88.8 348 162.2 424 256 424c41.1 0 80-14.7 110.6-41.6 4.7-4.2 11.9-3.9 16.4 .6l39.7 39.7c4.9 4.9 4.6 12.8-.5 17.4C378.2 479.8 319.9 504 256 504 119 504 8 393 8 256 8 119.2 119.6 7.8 256.5 8z"/></svg>

After

Width:  |  Height:  |  Size: 625 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>

After

Width:  |  Height:  |  Size: 464 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg>

After

Width:  |  Height:  |  Size: 486 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M242.7 256l100.1-100.1c12.3-12.3 12.3-32.2 0-44.5l-22.2-22.2c-12.3-12.3-32.2-12.3-44.5 0L176 189.3 75.9 89.2c-12.3-12.3-32.2-12.3-44.5 0L9.2 111.5c-12.3 12.3-12.3 32.2 0 44.5L109.3 256 9.2 356.1c-12.3 12.3-12.3 32.2 0 44.5l22.2 22.2c12.3 12.3 32.2 12.3 44.5 0L176 322.7l100.1 100.1c12.3 12.3 32.2 12.3 44.5 0l22.2-22.2c12.3-12.3 12.3-32.2 0-44.5L242.7 256z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

View file

@ -0,0 +1,218 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { WindowInfo } from "./ui_utils.slint";
import { PageBase } from "page-base.slint";
import { CityWeatherTile } from "city_weather_tile.slint";
import { ExpandedCityWeatherTile } from "expanded_city_weather_tile.slint";
import { CityWeather, CityWeatherInfo } from "weather_datatypes.slint";
import { AppPalette, AppImages } from "./style/styles.slint";
import { SlideButton } from "./controls/generic.slint";
import { AboutBox } from "about-box.slint";
component CitySlideArea inherits Rectangle {
in property<bool> can-move-up: true;
in property<bool> can-move-down: true;
callback opened;
callback closed;
callback up-clicked;
callback down-clicked;
callback delete-clicked;
callback content-clicked;
public function open() {
flickable.viewport-x = -buttons-layout.width;
root.opened();
}
public function close() {
flickable.viewport-x = 0;
root.closed();
}
height: content.preferred-height;
flickable := Flickable {
width: 100%;
viewport-width: slide-layout.preferred-width;
viewport-x: 0;
property<length> last-viewport-x: 0px;
flicked => {
if (self.last-viewport-x > self.viewport-x) {
root.open();
}
else {
root.close();
}
}
slide-layout := HorizontalLayout {
content := Rectangle {
width: root.width;
Rectangle {
@children
}
TouchArea {
pointer-event(event) => {
if (event.kind == PointerEventKind.down) {
flickable.last-viewport-x = flickable.viewport-x;
}
}
clicked => { root.content-clicked(); }
}
}
buttons-layout := VerticalLayout {
width: 50px;
spacing: 1px;
SlideButton {
icon-source: AppImages.arrow-up;
enabled: root.can-move-up;
clicked => { root.up-clicked(); }
}
SlideButton {
icon-source: AppImages.trash;
background-color: AppPalette.error-red;
clicked => { root.delete-clicked(); }
}
SlideButton {
icon-source: AppImages.arrow-down;
enabled: root.can-move-down;
clicked => { root.down-clicked(); }
}
}
}
}
}
component CityWeatherList inherits Flickable {
property<int> opened-index: -1;
callback expand(int, Point, length, length);
VerticalLayout {
alignment: start;
padding: 0px;
for city-weather-info[index] in CityWeather.city-weather:
CitySlideArea {
property<bool> is-opened: root.opened-index == index;
can-move-up: index > 0;
can-move-down: index < CityWeather.city-weather.length - 1;
changed is-opened => {
if (is-opened) {
self.open();
}
else {
self.close();
}
}
opened => {
root.opened-index = index;
}
content-clicked => {
root.opened-index = -1;
root.expand(index, self.absolute-position, self.width, self.height);
}
up-clicked => {
root.opened-index = index - 1;
CityWeather.reorder(index, root.opened-index);
}
down-clicked => {
root.opened-index = index + 1;
CityWeather.reorder(index, root.opened-index);
}
delete-clicked => {
root.opened-index = -1;
CityWeather.delete(index);
self.close();
}
CityWeatherTile {
city-weather-info: city-weather-info;
alternative-background: Math.mod(index, 2) == 0;
}
}
Rectangle {
// spacing
min-height: WindowInfo.is-portrait ? 25px : 10px;
}
AboutBox {
min-height: self.preferred-height;
}
}
}
struct TileInfo {
index: int,
absolute-position: Point,
size: { width: length, height: length }
}
export component CityListView inherits PageBase {
property<TileInfo> selected-tile;
CityWeatherList {
padding: 16px;
expand(index, absolute-position, width, height) => {
root.selected-tile = {
index: index,
absolute-position: absolute-position,
size: { width: width, height: height }
};
expanded-tile.expand();
}
}
PageBase {
opacity: 0.0;
states [
visible when expanded-tile.expanded: {
opacity: 1;
out {
animate opacity { delay: expanded-tile.animation-duration; }
}
}
]
}
expanded-tile := ExpandedCityWeatherTile {
city-weather-info: CityWeather.city-weather[root.selected-tile.index];
alternative-background: Math.mod(root.selected-tile.index, 2) == 0;
block-x: root.selected-tile.absolute-position.x - root.absolute-position.x;
block-y: root.selected-tile.absolute-position.y - root.absolute-position.y;
block-width: root.selected-tile.size.width;
block-height: root.selected-tile.size.height;
full-x: 0px;
full-y: 0px;
full-width: parent.width;
full-height: parent.height;
clicked => {
self.collapse();
}
}
}

View file

@ -0,0 +1,108 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { WeatherInfo, WeatherForecastInfo, CityWeatherInfo, CityWeather } from "weather_datatypes.slint";
import { WindowInfo } from "./ui_utils.slint";
import { AppPalette } from "./style/styles.slint";
import { AppText } from "./controls/generic.slint";
import { WeatherIcon } from "./controls/weather.slint";
import { DayForecastGraph } from "./forecast_with_graph.slint";
component TileBaseInfo inherits HorizontalLayout {
in property<string> city-name;
in property<WeatherInfo> current-weather;
spacing: 15px;
AppText {
font-size: 2.1rem;
text: root.city-name;
}
VerticalLayout {
horizontal-stretch: 1;
alignment: start;
AppText {
min-width: self.preferred-width;
font-size: 1.8rem;
horizontal-alignment: right;
text: Math.round(root.current-weather.current_temp) + "°";
}
AppText {
font-size: 1.2rem;
horizontal-alignment: right;
text: root.current-weather.description;
wrap: word-wrap;
}
}
WeatherIcon {
icon-type: root.current-weather.icon-type;
font-size: 3.5rem;
vertical-alignment: top;
}
}
export component CityWeatherTile inherits TouchArea {
in property<CityWeatherInfo> city-weather-info;
in property<bool> alternative-background: false;
in property <bool> show-animations: true;
out property<string> city-name: city-weather-info.city-name;
out property<WeatherInfo> current-weather: city-weather-info.current-weather;
out property<[WeatherForecastInfo]> forecast-weather: city-weather-info.forecast-weather;
preferred-height: layout.preferred-height;
Rectangle {
background: root.alternative-background ? white.with-alpha(2.5%) : white.with-alpha(8.5%);
}
layout := VerticalLayout {
padding: 15px;
spacing: 10px;
if WindowInfo.is-portrait: VerticalLayout {
vertical-stretch: 0;
spacing: parent.spacing;
TileBaseInfo {
city-name: root.city-name;
current-weather: root.current-weather;
}
DayForecastGraph {
min-height: self.preferred-height;
forecast-weather: root.forecast-weather;
show-animations: root.show-animations;
}
}
if !WindowInfo.is-portrait: HorizontalLayout {
spacing: 5% * self.width;
TileBaseInfo {
horizontal-stretch: 1;
max-width: parent.width * 40%;
height: self.preferred-height;
city-name: root.city-name;
current-weather: root.current-weather;
}
Rectangle {
min-height: self.preferred-height;
DayForecastGraph {
forecast-weather: root.forecast-weather;
show-animations: root.show-animations;
}
}
}
}
}

View file

@ -0,0 +1,50 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { AppFonts, AppImages } from "../style/styles.slint";
export global BusyLayerController {
out property<bool> is-busy: false;
property<int> busy-counter: 0;
public function set-busy() {
busy-counter += 1;
// updating only when real change happen to avoid:
// https://github.com/slint-ui/slint/issues/5209
if (!root.is-busy) {
root.is-busy = true;
}
}
public function unset-busy() {
busy-counter -= 1;
// updating only when real change happen to avoid:
// https://github.com/slint-ui/slint/issues/5209
if (root.is-busy && busy-counter == 0) {
root.is-busy = false;
}
}
}
export component BusyLayer inherits Rectangle {
Rectangle {
background: black;
opacity: 0.75;
}
Image {
width: 75px;
height: 75px;
image-fit: contain;
source: AppImages.refresh;
colorize: white.darker(15%);
rotation-angle: Math.mod(animation-tick() / 3.25ms, 360) * 1deg;
}
// touch blocker
TouchArea {}
}

View file

@ -0,0 +1,123 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { AppPalette, AppFonts, AppImages } from "../style/styles.slint";
export component AppText inherits Text {
color: AppPalette.foreground;
overflow: elide;
}
export component AppIcon inherits Image {
image-fit: contain;
colorize: AppPalette.foreground;
}
export component FloatingTextButton inherits Rectangle {
in property icon-source <=> icon.source;
in property icon-color <=> icon.colorize;
callback clicked;
drop-shadow-color: self.background.darker(50%);
drop-shadow-blur: 5px;
drop-shadow-offset-x: 3px;
drop-shadow-offset-y: 2px;
background: AppPalette.background;
border-radius: Math.min(self.width, self.height) / 2;
padding: 12px;
padding-left: self.padding;
padding-right: self.padding;
padding-top: self.padding;
padding-bottom: self.padding;
preferred-width: 48px;
preferred-height: 48px;
width: self.preferred-width;
height: self.preferred-height;
icon := AppIcon {
width: parent.width - parent.padding-left - parent.padding-right;
height: parent.height - parent.padding-top - parent.padding-bottom;
}
TouchArea {
clicked => { root.clicked(); }
}
}
export component SlideButton inherits Rectangle {
in-out property icon-source <=> icon.source;
in-out property<bool> enabled <=> touch-area.enabled;
in property<color> background-color;
callback clicked <=> touch-area.clicked;
background: touch-area.pressed ? self.background-color.darker(10%) : self.background-color;
opacity: root.enabled ? 1.0 : 0.5;
icon := AppIcon {
width: 50%;
height: 50%;
colorize: touch-area.pressed ? AppPalette.foreground.darker(10%) : AppPalette.foreground;
}
touch-area := TouchArea {}
}
export component TextField inherits Rectangle {
in property icon-source <=> icon.source;
in property<string> placeholder-text;
in-out property<string> text <=> text-input.text;
callback edited <=> text-input.edited;
forward-focus: text-input;
padding: 5px;
padding-top: self.padding;
padding-right: self.padding;
padding-bottom: self.padding;
padding-left: self.padding;
preferred-height: text-input.preferred-height + self.padding-top + self.padding-bottom;
height: self.preferred-height;
border-radius: 5px;
background: white.with-alpha(15%);
HorizontalLayout {
x: root.padding-left;
width: parent.width - root.padding-left - root.padding-right;
y: root.padding-top;
height: parent.height - root.padding-top - root.padding-bottom;
spacing: icon.preferred-width > 0 ? 10px : 0px;
icon := AppIcon {
max-width: self.height;
}
AppText {
horizontal-stretch: 1;
horizontal-alignment: left;
font-size: text-input.font-size;
text: text-input.text == "" ? root.placeholder-text : "";
text-input := TextInput {
color: AppPalette.foreground;
font-size: 1.2rem;
}
}
AppIcon {
source: AppImages.xmark;
max-width: self.height;
}
}
}

View file

@ -0,0 +1,136 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
/*!
* \brief The StackView is a component that can be used to simulate a stack view.
*
* \note: Due to the language limitation only the partial implementaion is possible.
* The component is meant to be a helper.
*
* \par Usage:
* The component can be used in two ways:
* - for smaller pages, where all pages are loaded at once,
* - for more complex pages, where pages are loaded dynamically.
*
* Static pages:
* \code{*.slint}
* stack := StackView {
* current-index: 0;
* min-index: 0;
*
* for color in [ Colors.red, Colors.green, Colors.blue ]:
* StackPage {
* is-current: self.check-is-current(stack.current-index);
* init => { self.page-index = stack.insert-page(); } // StackPage.count increased with insert-page function
*
* TestPage {
* background: color;
* push => { stack.push(); }
* pop => { stack.pop(); }
* }
* }
* }
* \endcode
*
* Dynamic pages:
* \code{*.slint}
* stack := StackView {
* count: 2; // StackPage.count provided manually
* current-index: 0;
* min-index: 0;
*
* if (stack.current-index == 0): StackPage {
* page-index: 0; is-current: true;
*
* TestPage {
* background: Colors.red;
* push => { stack.push(); }
* pop => { stack.pop(); }
* }
* }
* if (stack.current-index == 1): StackPage {
* page-index: 1; is-current: true;
*
* TestPage {
* background: Colors.green;
* push => { stack.push(); }
* pop => { stack.pop(); }
* }
* }
* }
* \endcode
*
* \sa StackPage
*/
export component StackView inherits Rectangle {
/// \brief This property states the number of items in the stack
in-out property<int> count: 0;
/// \brief This property states the index of the currently visible item
in-out property<int> current-index: -1;
/// \brief This property configures the minimum index the pop function can set (-1 by default)
in property<int> min-index: -1;
/// \brief This property configures the minimum index the push function can set (#count -1 by default)
in property<int> max-index: self.count - 1;
/// \brief This function increases the pages #count by one and returns new page index
public function insert-page() -> int {
self.count += 1;
return self.count - 1;
}
/// \brief This function increases the #current-index if possible
public function push() {
if (self.current-index < Math.min(self.max-index, self.count - 1)) {
self.current-index += 1;
}
}
/// \brief This function decreased the #current-index if possible
public function pop() {
if (self.current-index > Math.max(self.min-index, -1)) {
self.current-index -= 1;
}
}
}
/*!
* \brief The StackPage is a component to use in the StackView.
*
* The real content can either derive from the StackPage (which is Rectangle based) or can be contained
* as the page children.
*
* Inherits:
* \code{*.slint}
* export TestPage inherits StackPage {
* background: color;
* }
* \endcode
*
* Contains:
* \code{*.slint}
* StackPage {
* TestPage {
* background: Colors.red;
* }
* }
* \endcode
*
* \sa StackView
*/
export component StackPage inherits TouchArea {
/// \brief This property configures the page index
in property<int> page-index: -1;
/// \brief This property configures whether the page is a current page (if not, it is hidden)
in property<bool> is-current: false;
/// \brief This function is a helper function to use when setting the #is-current property
public pure function check-is-current(current-index: int) -> bool {
return current-index == self.page-index;
}
visible: self.is-current;
}

View file

@ -0,0 +1,158 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { AppPalette, AppFonts } from "../style/styles.slint";
import { AppText } from "./generic.slint";
import { WindowInfo } from "../ui_utils.slint";
import { IconType } from "../weather_datatypes.slint";
export component WeatherIconBase inherits Text {
color: AppPalette.foreground;
font-family: AppFonts.weather-icons-font-name;
horizontal-alignment: center;
vertical-alignment: center;
}
export component WeatherIcon inherits WeatherIconBase {
in property<IconType> icon-type;
pure function get-weather-icon-url(type: IconType) -> string {
if (type == IconType.Sunny) { return "\u{f00d}"; }
if (type == IconType.PartiallyCloudy) { return "\u{f002}"; }
if (type == IconType.MostlyCloudy) { return "\u{f041}"; }
if (type == IconType.Cloudy) { return "\u{f013}"; }
if (type == IconType.SunnyRainy) { return "\u{f008}"; }
if (type == IconType.Rainy) { return "\u{f015}"; }
if (type == IconType.Stormy) { return "\u{f01e}"; }
if (type == IconType.Snowy) { return "\u{f064}"; }
if (type == IconType.Foggy) { return "\u{f063}"; }
return "";
}
pure function get-weather-icon-color(type: IconType) -> color {
if (type == IconType.Sunny) { return AppPalette.sun-yellow; }
return AppPalette.foreground;
}
text: root.get-weather-icon-url(root.icon-type);
color: root.get-weather-icon-color(root.icon-type);
}
component DataText inherits AppText {
in property<bool> minimal: false;
font-size: root.minimal ? (WindowInfo.is-portrait ? 0.85rem : 0.9rem) : 1.1rem;
overflow: elide;
horizontal-alignment: center;
vertical-alignment: center;
}
export component RainInfo inherits Rectangle {
in property<float> precipitation-probability;
in property<float> rain-volume;
in property<float> snow-volume;
in property<bool> minimal: false;
property<bool> is-snow: root.snow-volume > root.rain-volume;
property<float> volume: Math.max(root.rain-volume, root.snow-volume);
property<float> probability: Math.round(root.precipitation-probability * 100);
property<string> volume-display: Math.round(volume * 10) / 10;
property<string> type-indicator: self.is-snow ? "\u{f076}" : "\u{f078}";
property<color> type-color: self.is-snow ? AppPalette.snow-white : AppPalette.rain-blue;
property<float> max-bar-volume: 10;
padding-top: 2px;
padding-bottom: 2px;
opacity: root.minimal ? (root.precipitation-probability * 30% + 70%) : 100%;
if !root.minimal: Rectangle {
x: parent.width - self.width;
y: parent.height - self.height;
width: 3px;
height: (parent.height - parent.padding-top - parent.padding-bottom) *
Math.min(root.volume, root.max-bar-volume) / root.max-bar-volume * 100%;
background: root.type-color;
opacity: root.precipitation-probability * 70% + 30%;
}
VerticalLayout {
HorizontalLayout {
alignment: center;
spacing: 3px;
DataText {
minimal: root.minimal;
text: "\{root.probability}%";
color: root.type-color;
}
if !root.minimal || !WindowInfo.is-portrait: WeatherIcon {
font-size: root.minimal ? 0.9rem : 1rem;
text: "\{root.type-indicator}";
color: root.type-color;
}
if root.minimal && WindowInfo.is-portrait: DataText {
minimal: true;
text: "/ \{root.volume-display}l";
color: root.type-color;
}
}
if !root.minimal || !WindowInfo.is-portrait: DataText {
minimal: root.minimal;
text: "\{root.volume-display}l";
color: root.type-color;
}
}
}
export component UvInfo inherits Rectangle {
in property<int> uv-index;
in property<bool> minimal: false;
property<float> uv-index-rate: (root.uv-index / 12.0);
opacity: root.minimal ? (root.uv-index-rate * 30% + 70%) : 100%;
HorizontalLayout {
alignment: center;
spacing: 3px;
WeatherIcon {
text: "\u{f06e}";
font-size: root.minimal ? 0.9rem : 1.3rem;
opacity: root.minimal ? 100% : (root.uv-index-rate * 70% + 30%);
color: AppPalette.sun-yellow;
}
if root.minimal: DataText {
minimal: true;
text: "UV";
color: AppPalette.sun-yellow;
}
DataText {
minimal: root.minimal;
text: "\{root.uv-index}";
color: AppPalette.sun-yellow;
}
}
}

View file

@ -0,0 +1,285 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
import { WindowInfo } from "./ui_utils.slint";
import { AppPalette } from "./style/styles.slint";
import { AppText } from "./controls/generic.slint";
import { WeatherIcon, RainInfo, UvInfo } from "./controls/weather.slint";
import { WeatherInfo, WeatherForecastInfo, CityWeatherInfo } from "weather_datatypes.slint";
import { CityWeather } from "weather_datatypes.slint";
import { CityWeatherTile } from "city_weather_tile.slint";
component ForecastDayLineBase inherits Rectangle {
out property<{temp: length, rain: length, uv: length}> fields-width: {
temp: 90px, rain: 65px, uv: 65px
};
}
component ForecastDataText inherits AppText {
font-size: 1.1rem;
overflow: elide;
horizontal-alignment: center;
vertical-alignment: center;
}
component ForecastTitleText inherits ForecastDataText {
font-size: 1.25rem;
font-weight: 500;
letter-spacing: 1pt;
}
component ForecastTitleLine inherits ForecastDayLineBase {
HorizontalLayout {
// spacer
Rectangle { horizontal-stretch: 1; }
ForecastTitleText {
preferred-width: root.fields-width.temp;
text: @tr("Max/Min");
}
ForecastTitleText {
preferred-width: root.fields-width.rain;
text: @tr("Rain");
}
ForecastTitleText {
preferred-width: root.fields-width.uv;
text: @tr("UV");
}
if !WindowInfo.is-portrait: HorizontalLayout {
width: WindowInfo.is-portrait ? 0% : 50%;
for description[index] in [ @tr("Morning"), @tr("Day"), @tr("Evening"), @tr("Night") ]:
ForecastTitleText {
width: 25%;
text: description;
}
}
}
}
component ForecastDayLine inherits ForecastDayLineBase {
in property<string> day-name;
in property<WeatherInfo> day-weather;
height: 50px;
HorizontalLayout {
// spacer
Rectangle { width: 5px; }
name-text := ForecastDataText {
horizontal-stretch: 1;
min-width: self.preferred-width;
horizontal-alignment: left;
text: root.day-name;
font-size: 1.2rem;
}
WeatherIcon {
icon-type: root.day-weather.icon_type;
font-size: 1.8rem;
visible: WindowInfo.window-width >= 360px;
}
// spacer
Rectangle {
max-width: WindowInfo.window-width >= 380px ? self.preferred-width : 0;
preferred-width: 15px;
}
ForecastDataText {
property<int> min-temp: Math.round(root.day-weather.detailed_temp.min);
property<int> max-temp: Math.round(root.day-weather.detailed_temp.max);
preferred-width: root.fields-width.temp;
text: "\{self.max-temp}° / \{self.min-temp}°";
}
RainInfo {
precipitation-probability: root.day-weather.precipitation_prob;
rain-volume: root.day-weather.rain;
snow-volume: root.day-weather.snow;
preferred-width: root.fields-width.rain;
}
UvInfo {
uv-index: root.day-weather.uv;
preferred-width: root.fields-width.uv;
}
}
}
component ForecastDayDetails inherits HorizontalLayout {
in property<WeatherInfo> day-weather;
property<[{ time: string, temp: float }]> temp-model: [
{ time: "\u{f051}", temp: Math.round(root.day-weather.detailed_temp.morning) },
{ time: "\u{f00d}", temp: Math.round(root.day-weather.detailed_temp.day) },
{ time: "\u{f052}", temp: Math.round(root.day-weather.detailed_temp.evening) },
{ time: "\u{f02e}", temp: Math.round(root.day-weather.detailed_temp.night) },
];
padding-top: WindowInfo.is-portrait ? 10px : 0;
padding-bottom: WindowInfo.is-portrait ? 10px : 0;
for time-temp in root.temp-model:
HorizontalLayout {
width: 25%;
alignment: center;
spacing: 5px;
WeatherIcon {
text: "\{time-temp.time}";
font-size: 1.3rem;
}
ForecastDataText {
font-size: 1.3rem;
text: "\{time-temp.temp}°";
}
}
}
component ForecastDayDelegate inherits TouchArea {
in property<bool> expanded: false;
in property<bool> alternative-background: false;
in property<string> day-name;
in property<WeatherInfo> day-weather;
animate height { duration: 250ms; easing: ease-in-out-quad; }
height: root.expanded ? self.preferred-height : main-info-line.preferred-height;
Rectangle {
background: root.alternative-background ? Colors.white.transparentize(80%) : transparent;
clip: true;
VerticalLayout {
main-info-line := HorizontalLayout {
ForecastDayLine {
day-name: root.day-name;
day-weather: root.day-weather;
}
if !WindowInfo.is-portrait: ForecastDayDetails {
width: 50%;
day-weather: root.day-weather;
}
}
if WindowInfo.is-portrait: ForecastDayDetails {
day-weather: root.day-weather;
}
}
}
}
export component ExpandedCityWeatherTile inherits TouchArea {
in property<CityWeatherInfo> city-weather-info <=> base-tile.city-weather-info;
in property<bool> alternative-background <=> base-tile.alternative-background;
out property<bool> expanded: false;
in-out property<duration> animation-duration: 300ms;
in property<length> block-x;
in property<length> block-y;
in property<length> block-width;
in property<length> block-height;
in property<length> full-x;
in property<length> full-y;
in property<length> full-width;
in property<length> full-height;
public function expand() {
// Not working properly without the lines below. (A bug?)
// Seems the animation in transition using old values, and
// accessing the properties somehow forces the update.
root.x; root.y; root.width; root.height;
details-rect.height;
root.expanded = true;
}
public function collapse() {
root.expanded = false;
}
x: self.block-x;
y: self.block-y;
width: self.block-width;
height: self.block-height;
visible: self.opacity > 0;
opacity: 0;
states [
full-size when root.expanded: {
opacity: 1;
x: full-x;
y: full-y;
width: full-width;
height: full-height;
in {
animate x, y, width, height { duration: root.animation-duration; easing: ease-in-out-quad; }
}
out {
animate x, y, width, height { duration: root.animation-duration; easing: ease-in-out-quad; }
animate opacity { delay: root.animation-duration; }
}
}
]
VerticalLayout {
base-tile := CityWeatherTile {
show-animations: false;
clicked => {
root.clicked();
}
}
details-rect := Rectangle {
vertical-stretch: 1;
clip: true;
Flickable {
padding-top: 15px;
padding-bottom: 15px;
y: self.padding-top;
height: parent.height - self.padding-top - self.padding-bottom;
VerticalLayout {
alignment: start;
padding-left: 15px;
padding-right: 15px;
ForecastTitleLine {}
for day-forecast-weather[index] in root.city-weather-info.forecast-weather:
ForecastDayDelegate {
alternative-background: Math.mod(index, 2) == 0;
day-name: day-forecast-weather.day-name;
day-weather: day-forecast-weather.weather-info;
clicked => {
self.expanded = !self.expanded;
}
}
}
}
}
}
}

View file

@ -0,0 +1,128 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { VerticalBox } from "std-widgets.slint";
import { AppPalette } from "./style/styles.slint";
import { AppText } from "./controls/generic.slint";
import { WeatherIcon, RainInfo, UvInfo } from "./controls/weather.slint";
import { WeatherInfo, WeatherForecastInfo, CityWeather } from "./weather_datatypes.slint";
component ForecastGraphText inherits AppText {
horizontal-alignment: center;
vertical-alignment: center;
font-size: 0.85rem;
}
component DayForecastGraphEntry inherits VerticalLayout {
in property <string> day-name;
in property <WeatherInfo> day-weather;
in property <bool> detailed: true;
spacing: 5px;
ForecastGraphText {
font-size: 1.2rem;
text: day-name;
}
WeatherIcon {
icon-type: day-weather.icon-type;
font-size: 1.6rem;
}
VerticalLayout {
spacing: 5px;
ForecastGraphText {
text: Math.round(day-weather.detailed_temp.max) + "° / " + Math.round(day-weather.detailed_temp.min) + "°";
}
RainInfo {
precipitation-probability: root.day-weather.precipitation_prob;
rain-volume: root.day-weather.rain;
snow-volume: root.day-weather.snow;
minimal: true;
}
UvInfo {
uv-index: root.day-weather.uv;
minimal: true;
}
}
}
export component DayForecastGraph inherits Rectangle {
in property <[WeatherForecastInfo]> forecast-weather;
in property <bool> show-animations: true;
property <length> preferred-day-width: 85px;
// max-days-count is not directly as a binding here, only when the value is actually changed.
// This is to avoid reevaluation of the conditional components that rely on it for every window size change.
// see: https://github.com/slint-ui/slint/issues/5209
property <int> max-days-count: 0;
property <int> days-count: Math.min(root.forecast-weather.length, root.max-days-count);
property <length> day-width: root.width / root.days-count;
function update-max-days-count() {
if (Math.floor(root.width / root.preferred-day-width) != root.max-days-count) {
root.max-days-count = Math.floor(root.width / root.preferred-day-width);
}
}
init => { root.update-max-days-count(); }
changed width => { root.update-max-days-count(); }
preferred-height: layout.preferred-height;
Path {
property <float> visible-part: 0%;
y: 0;
height: 50%;
stroke-width: 2px;
commands: CityWeather.get_forecast_graph_command(
root.forecast-weather, root.days-count, self.width, self.height);
stroke: @linear-gradient(90deg, AppPalette.foreground.with-alpha(25%) 0%,
AppPalette.foreground.with-alpha(25%) self.visible-part,
transparent self.visible-part,
transparent 100%);
opacity: 0.0;
animate opacity { duration: root.show-animations ? 1200ms : 0ms; easing: ease-in; }
animate visible-part { duration: root.show-animations ? 900ms : 0ms; easing: ease-in; }
init => {
self.opacity = 1.0;
self.visible-part = 100%;
}
}
layout := HorizontalLayout {
for index in root.days-count:
DayForecastGraphEntry {
property <WeatherForecastInfo> day-forecast-weather: root.forecast-weather[index];
property <duration> animation-duration: 0ms;
width: root.day-width;
day-name: day-forecast-weather.day-name;
day-weather: day-forecast-weather.weather-info;
opacity: 0.0;
animate opacity { duration: self.animation-duration; easing: ease-in-out-quad; }
init => {
if (root.show-animations) {
self.animation-duration = 600ms + (500ms - index * 50ms) * index;
}
self.opacity = 1.0;
}
}
}
}

View file

@ -0,0 +1,17 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
export struct GeoLocationEntry {
name: string,
state: string,
country: string,
lat: float,
lon: float,
}
export global GeoLocation {
in property <[GeoLocationEntry]> result-list;
callback search-location(string);
callback add-location(GeoLocationEntry);
}

View file

@ -0,0 +1,68 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { PageBase } from "page-base.slint";
import { AppImages } from "./style/styles.slint";
import { AppText, TextField } from "./controls/generic.slint";
import { BusyLayerController } from "./controls/busy-layer.slint";
import { GeoLocation } from "./location_datatypes.slint";
import { Button } from "std-widgets.slint";
export component LocationSearchView inherits PageBase {
callback close-request;
public function clear() {
GeoLocation.search_location("");
text-field.text = "";
}
forward-focus: text-field;
VerticalLayout {
padding: 20px;
spacing: 10px;
text-field := TextField {
icon-source: AppImages.search;
placeholder-text: "Search";
edited => {
GeoLocation.search_location(self.text);
}
}
Flickable {
VerticalLayout {
alignment: start;
for data[index] in GeoLocation.result-list : Rectangle {
preferred-height: layout.preferred-height + 20px;
min-height: self.preferred-height;
layout := VerticalLayout {
alignment: center;
spacing: 5px;
AppText {
text: data.name;
font-size: 1.3rem;
}
AppText {
text: data.state == "" ? data.country : data.state + ", " + data.country;
font-size: 0.9rem;
}
}
TouchArea {
clicked => {
BusyLayerController.set-busy();
GeoLocation.add-location(data);
root.close-request();
}
}
}
}
}
}
}

View file

@ -0,0 +1,195 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { Palette } from "std-widgets.slint";
import { WindowInfo, WindowInfoHelper } from "./ui_utils.slint";
import { StackView, StackPage } from "./controls/stackview.slint";
import { CityListView } from "./city_weather.slint";
import { CityWeather } from "./weather_datatypes.slint";
import { LocationSearchView } from "./location_search.slint";
import { GeoLocation } from "./location_datatypes.slint";
import { AppPalette, AppFonts, AppImages } from "./style/styles.slint";
import { FloatingTextButton } from "./controls/generic.slint";
import { BusyLayerController, BusyLayer } from "./controls/busy-layer.slint";
// Re export for native rust
export { WindowInfo, AppPalette, BusyLayerController, CityWeather, GeoLocation }
component EdgeFloatingTextButton inherits FloatingTextButton {
out property<length> edge-spacing: 15px;
}
component AnimatedStackPage inherits StackPage {
// is-active and is-opened are not set as a binding here, only when the value is actually changed.
// This is to avoid redundant reevaluation of dependent properties and conditional elements.
// see: https://github.com/slint-ui/slint/issues/5209
out property<bool> is-active: false;
out property<bool> is-opened: false;
// using a helper int property to be able to use animate
property<int> is-active-value: 0;
property<duration> animation-duration: 250ms;
visible: root.is-active;
init => { root.is-active = (self.is-active_value == 1); }
changed is-active-value => { root.is-active = (self.is-active_value == 1); }
states [
active when self.is-current: {
is-active-value: 1;
out {
animate is-active-value { delay: root.animation-duration; }
}
}
]
content := Rectangle {
changed y => {
// First open animation is not working properly without the line below. (A bug?)
// Seems the animation in transition is using old values,
// and accessing the property somehow forces the update.
self.y;
if (root.is-opened != (self.y == 0)) {
root.is-opened = (self.y == 0);
}
}
y: root.is-current ? 0px : root.height;
animate y { duration: root.animation-duration; easing: ease-in-out-quad; }
@children
}
}
enum PageType {
Main,
AddLocation,
}
export component AppWindow inherits Window {
background: AppPalette.background;
default-font-size: AppFonts.default-font-size;
preferred-width: 900px;
preferred-height: 600px;
WindowInfoHelper {
init => {
// no support for the different modes currently
// this is to display slint badge in proper colors
Palette.color-scheme = ColorScheme.dark;
}
}
// this component is added to overcome a slint issue with android cursor never being hidden
// see: https://github.com/slint-ui/slint/issues/5233
invisible-input := TextInput {
y: -self.height * 2;
}
stack := StackView {
function show-page(pageType : PageType) {
if (pageType == PageType.Main) {
self.current-index = 0;
}
else if (pageType == PageType.AddLocation) {
self.current-index = 1;
}
}
function back-to-main() {
self.show-page(PageType.Main);
}
current-index: 0;
min-index: 0;
StackPage {
is-current: self.check-is-current(stack.current-index);
init => { self.page-index = stack.insert-page(); }
visible: self.page-index <= stack.current-index;
CityListView {}
// right (refresh) button
EdgeFloatingTextButton {
x: parent.width - self.width - self.edge-spacing;
y: parent.height - self.height - self.edge-spacing;
icon-source: AppImages.refresh;
clicked => {
BusyLayerController.set-busy();
CityWeather.refresh-all();
}
}
// left (add) button
EdgeFloatingTextButton {
x: self.edge-spacing;
y: parent.height - self.height - self.edge-spacing;
visible: CityWeather.can-add-city;
icon-source: AppImages.plus;
clicked => {
stack.show-page(PageType.AddLocation);
}
}
}
AnimatedStackPage {
is-current: self.check-is-current(stack.current-index);
init => { self.page-index = stack.insert-page(); }
changed is-active => {
if (!self.is-active) {
// focusing invisible input so the cursor is drawn out of the window scope
invisible-input.focus();
// clearing focus right after to hide the keyboard
invisible-input.clear-focus();
}
}
location-search-view := LocationSearchView {
property<bool> is-active: parent.is-active;
property<bool> is-opened: parent.is-opened;
changed is-active => {
if (self.is-active) {
self.clear();
}
}
changed is-opened => {
if (self.is-opened) {
// we cannot focus on init, because android cursor is then wrongly positioned
self.focus();
}
}
close-request => {
self.clear-focus();
stack.back-to-main();
}
EdgeFloatingTextButton {
x: parent.width - self.width - self.edge-spacing;
y: parent.height - self.height - self.edge-spacing;
icon-source: AppImages.xmark;
clicked => { location-search-view.close-request(); }
}
}
}
}
if BusyLayerController.is-busy: BusyLayer {}
}

View file

@ -0,0 +1,8 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { AppPalette } from "style/styles.slint";
export component PageBase inherits Rectangle {
background: @linear-gradient(180deg, AppPalette.background 0%, AppPalette.alternate-background 100%);
}

View file

@ -0,0 +1,34 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
import { Palette } from "std-widgets.slint";
import "../assets/weathericons-font.ttf";
export global AppPalette {
out property<brush> background: #1673b4;
out property<brush> alternate-background: #2296bc;
out property<brush> foreground: white;
out property<brush> sun-yellow: Colors.yellow;
out property<brush> snow-white: Colors.cornsilk;
out property<brush> rain-blue: #7DCDFF.brighter(15%);
out property<brush> error-red: Colors.red.darker(20%);
}
export global AppFonts {
out property<length> default-font-size: 10pt;
out property<string> weather-icons-font-name: "Weather Icons";
}
export global AppImages {
out property <image> arrow-down: @image-url("../assets/icons/arrow-down.svg");
out property <image> arrow-up: @image-url("../assets/icons/arrow-up.svg");
out property <image> plus: @image-url("../assets/icons/plus.svg");
out property <image> refresh: @image-url("../assets/icons/refresh.svg");
out property <image> search: @image-url("../assets/icons/search.svg");
out property <image> trash: @image-url("../assets/icons/trash.svg");
out property <image> xmark: @image-url("../assets/icons/xmark.svg");
}

View file

@ -0,0 +1,30 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
export global WindowInfo {
// default values provided for the slint-viewer
in property<length> window-width: 400px;
in property<length> window-height: 700px;
// is-portrait is not set as a binding here, only when the value is actually changed.
// This is to avoid reevaluation of the conditional components that rely on it for every window size change.
// see: https://github.com/slint-ui/slint/issues/5209
in property<bool> is-portrait: true;
}
export component WindowInfoHelper inherits Rectangle {
function check-is-portrait() {
if (WindowInfo.is-portrait != (self.width < self.height)) {
WindowInfo.is-portrait = (self.width < self.height);
}
}
changed width => {
WindowInfo.window-width = self.width;
self.check-is-portrait();
}
changed height => {
WindowInfo.window-height = self.height;
self.check-is-portrait();
}
}

View file

@ -0,0 +1,57 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
export enum IconType {
Unknown,
Sunny,
PartiallyCloudy,
MostlyCloudy,
Cloudy,
SunnyRainy,
Rainy,
Stormy,
Snowy,
Foggy,
}
export struct TemperatureInfo {
min: float,
max:float,
morning: float,
day: float,
evening:float,
night:float,
}
export struct WeatherInfo {
description: string,
icon_type: IconType,
current_temp: float,
detailed_temp: TemperatureInfo,
uv: int,
precipitation_prob: float,
rain: float,
snow: float,
}
export struct WeatherForecastInfo {
day_name: string,
weather_info: WeatherInfo,
}
export struct CityWeatherInfo {
city_name: string,
current_weather: WeatherInfo,
forecast_weather: [WeatherForecastInfo],
}
export global CityWeather {
in property <[CityWeatherInfo]> city-weather;
in property <bool> can-add-city: false;
pure callback refresh-all();
pure callback delete(int);
pure callback reorder(int, int);
pure callback get_forecast_graph_command([WeatherForecastInfo], int, length, length) -> string;
}

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<style>
#loading {
margin-top: 20px;
text-align: center;
}
.overlay {
position: absolute;
display: flex;
top: 0;
width: 100%;
}
canvas {
top: 0;
padding-left: 0;
padding-right: 0;
margin-left: auto;
margin-right: auto;
margin-top: 20px;
display: block;
}
</style>
</head>
<body>
<div id="loading">Loading...</div>
<div class="overlay">
<!-- canvas required by the Slint runtime -->
<canvas id="canvas" data-slint-auto-resize-to-preferred="true" unselectable="on"></canvas>
<script type="module">
// import the generated file.
import init from "./pkg/rusty_weather_lib.js";
init();
</script>
</div>
</body>
</html>