1 Commits
Author SHA1 Message Date
hulthe 42ee66754c Add Gitea workflow that builds Android APKs
APK Builder / build-apk (aarch64) (push) Successful in 1m33s
APK Builder / build-apk (x86_64) (push) Successful in 1m32s
2026-01-11 10:36:45 +01:00
21 changed files with 1881 additions and 2480 deletions
+36
View File
@@ -0,0 +1,36 @@
# Compile and bundle the gui into Android APKs
# The resulting APKs are uploaded to gitea as artifacts, attached to the workflow run.
name: APK Builder
run-name: Build Android APK
on: [push]
jobs:
build-apk:
runs-on: ubuntu-latest
container:
image: docker.nubo.sh/inkopslista-builder:2026-01-11
strategy:
matrix:
arch: [aarch64, x86_64]
steps:
# git clone
- name: Check out repository code
uses: actions/checkout@v4
# set the name of the output .apk based on commit and architecture
- run: echo "SHA_SHORT=$(echo ${{ gitea.sha }} | head -c 8)" >> $GITEA_ENV
- run: echo "OUT_NAME=inkopslista.${{ env.SHA_SHORT }}.${{ matrix.arch }}.apk" >> $GITEA_ENV
# build the apk
- run: cargo apk build --lib --release -p inkopslista --target ${{ matrix.arch }}-linux-android
- run: mv "$CARGO_TARGET_DIR/release/apk/inkopslista.apk" "$OUT_NAME"
# upload the apk to gitea
- uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name : "${{ env.OUT_NAME }}"
path: "${{ env.OUT_NAME }}"
if-no-files-found: error
overwrite: true
retention-days: 360 # delete it after one year
-2
View File
@@ -3,5 +3,3 @@ target/
**/*.rs.bk
*.sqlite
dabase
Generated
+1617 -1851
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,7 +1,7 @@
##################
### BASE STAGE ###
##################
FROM rust:1.94.0 AS base
FROM rust:1.92.0 AS base
RUN rustup target add wasm32-unknown-unknown
RUN rustup target add x86_64-unknown-linux-musl
@@ -63,7 +63,8 @@ RUN wasm-pack build --release --target web
FROM scratch
ENV RUST_LOG="info"
ENV BIND="0.0.0.0:8000"
ENV ROCKET_ADDRESS="0.0.0.0"
ENV ROCKET_PORT="8000"
VOLUME ["/data"]
ENV DB_PATH="/data/db.sqlite"
+38 -39
View File
@@ -3,6 +3,44 @@ name = "inkopslista"
version = "0.0.0"
edition = "2024"
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "lib"]
[dependencies]
reqwest = { version = "0.12.26", features = ["json", "rustls-tls"], default-features = false }
inkopslista-lib = { path = "../lib" }
tokio = { version = "1.48.0", default-features = false, features = ["rt", "macros", "sync"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
anyhow = "1.0.100"
[target.'cfg(target_os = "linux")'.dependencies]
tokio = { version = "1.48.0", default-features = false, features = ["fs", "rt-multi-thread"] }
xdg = "3.0.0"
[target.'cfg(target_os = "android")'.dependencies]
tokio = { version = "1.48.0", default-features = false, features = ["rt-multi-thread"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2.106"
wasm-bindgen-futures = "0.4.56"
web-sys = "0.3.83"
[dependencies.slint]
version = "1.14.1"
default-features = false
features = [
"live-preview",
"backend-winit-wayland",
"renderer-femtovg",
"compat-1-2",
"backend-android-activity-06",
]
[build-dependencies]
slint-build = "1.14.1"
# See https://github.com/rust-mobile/cargo-apk?tab=readme-ov-file#manifest
[package.metadata.android]
package = "sh.nubo.Inkopslista"
@@ -25,42 +63,3 @@ name = "android.permission.INTERNET"
[[package.metadata.android.uses_permission]]
name = "android.permission.ACCESS_NETWORK_STATE"
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "lib"]
[dependencies]
reqwest = { version = "0.12.26", features = ["json", "rustls-tls"], default-features = false }
inkopslista-lib = { path = "../lib" }
tokio = { version = "1.48.0", default-features = false, features = ["rt", "macros", "sync"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
anyhow = "1.0.100"
[dependencies.slint]
version = "1.17.1"
default-features = false
features = [
"live-preview",
"backend-winit-wayland",
"renderer-femtovg",
"compat-1-2",
"backend-android-activity-06",
]
[target.'cfg(target_os = "linux")'.dependencies]
tokio = { version = "1.48.0", default-features = false, features = ["fs", "rt-multi-thread"] }
xdg = "3.0.0"
[target.'cfg(target_os = "android")'.dependencies]
tokio = { version = "1.48.0", default-features = false, features = ["rt-multi-thread"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2.106"
wasm-bindgen-futures = "0.4.56"
web-sys = "0.3.83"
[build-dependencies]
slint-build = "1.17.1"
+1 -11
View File
@@ -1,14 +1,4 @@
use std::env;
fn main() {
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").expect("target arch env");
let style = match target_arch.as_str() {
// For web, rely on dark mod detection based on browser settings.
"wasm32" => "cosmic",
// For any other target, force dark mode.
_ => "cosmic-dark",
};
let config = slint_build::CompilerConfiguration::new().with_style(style.into());
let config = slint_build::CompilerConfiguration::new().with_style("cosmic-dark".into());
slint_build::compile_with_config("ui/app.slint", config).expect("Slint build failed");
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -ex
cd "$(dirname 0)"
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-../../target}"
# TODO: cross compilation with cross --target=aarch64-unknown-linux-gnu
cargo b -r
cp "$CARGO_TARGET_DIR/release/inkopslista" inköpslista
# TODO: cross compilation with --arch=aarch64
flatpak-builder -v --force-clean --user --install-deps-from=flathub --repo=repo --install builddir sh.nubo.Inkopslista.yml
+1 -1
View File
@@ -1,6 +1,6 @@
[Desktop Entry]
Name=Inköpslista
Exec=inkopslista %U
Exec=inköpslista %U
Terminal=false
Type=Application
Icon=sh.nubo.Inkopslista
+29 -20
View File
@@ -2,34 +2,43 @@ id: sh.nubo.Inkopslista
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
build-args:
- --share=network
command: 'inkopslista'
command: 'inköpslista'
modules:
- name: inköpslista
buildsystem: simple
sources:
- type: dir
path: ../..
# - type: script
# dest-filename: gui
# commands:
# - echo "hello there"
- type: file
path: ./sh.nubo.Inkopslista.desktop
- type: file
path: ./inköpslista
- type: file
path: ../images/icon.svg
- type: file
path: ../images/icon_x512.png
- type: file
path: ../images/icon_x192.png
- type: file
path: ../images/icon_x144.png
- type: file
path: ../images/icon_x128.png
- type: file
path: ../images/icon_x72.png
build-commands:
- cargo build --release --locked
- install -Dm755 target/release/inkopslista $FLATPAK_DEST/bin/inkopslista
- install -Dm755 inköpslista $FLATPAK_DEST/bin/inköpslista
- install -Dm644 sh.nubo.Inkopslista.desktop $FLATPAK_DEST/share/applications/sh.nubo.Inkopslista.desktop
- install -Dm644 gui/flatpak/sh.nubo.Inkopslista.desktop $FLATPAK_DEST/share/applications/sh.nubo.Inkopslista.desktop
- install -Dm644 gui/images/icon.svg $FLATPAK_DEST/share/icons/hicolor/scalable/apps/sh.nubo.Inkopslista.svg
- install -Dm644 gui/images/icon_x512.png $FLATPAK_DEST/share/icons/hicolor/512x512/apps/sh.nubo.Inkopslista.png
- install -Dm644 gui/images/icon_x192.png $FLATPAK_DEST/share/icons/hicolor/192x192/apps/sh.nubo.Inkopslista.png
- install -Dm644 gui/images/icon_x144.png $FLATPAK_DEST/share/icons/hicolor/144x144/apps/sh.nubo.Inkopslista.png
- install -Dm644 gui/images/icon_x128.png $FLATPAK_DEST/share/icons/hicolor/128x128/apps/sh.nubo.Inkopslista.png
- install -Dm644 gui/images/icon_x72.png $FLATPAK_DEST/share/icons/hicolor/72x72/apps/sh.nubo.Inkopslista.png
- install -Dm644 icon.svg $FLATPAK_DEST/share/icons/hicolor/scalable/apps/sh.nubo.Inkopslista.svg
- install -Dm644 icon_x512.png $FLATPAK_DEST/share/icons/hicolor/512x512/apps/sh.nubo.Inkopslista.png
- install -Dm644 icon_x192.png $FLATPAK_DEST/share/icons/hicolor/192x192/apps/sh.nubo.Inkopslista.png
- install -Dm644 icon_x144.png $FLATPAK_DEST/share/icons/hicolor/144x144/apps/sh.nubo.Inkopslista.png
- install -Dm644 icon_x128.png $FLATPAK_DEST/share/icons/hicolor/128x128/apps/sh.nubo.Inkopslista.png
- install -Dm644 icon_x72.png $FLATPAK_DEST/share/icons/hicolor/72x72/apps/sh.nubo.Inkopslista.png
finish-args:
# Wayland access
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Arrow / Arrow_Down_Up">
<path id="Vector" d="M11 16L8 19M8 19L5 16M8 19V5M13 8L16 5M16 5L19 8M16 5V19" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 350 B

+5 -6
View File
@@ -18,24 +18,23 @@ pub use fs::*;
#[cfg(target_os = "linux")]
mod fs {
use crate::runtime::spawn;
use super::Config;
use anyhow::{Context, Ok};
use serde_json;
const CONFIG_FILE_NAME: &str = "config.json";
const XDG_PREFIX: &str = "inköpslista";
const CONFIG_FILE_NAME: &'static str = "config.json";
const XDG_PREFIX: &'static str = "inköpslista";
pub fn save_config(address: String) -> Result<(), anyhow::Error> {
let xdg_dirs = xdg::BaseDirectories::with_prefix(XDG_PREFIX);
let conf = Config { address };
let conf = Config { address: address };
let conf = serde_json::to_string(&conf).context("unable to serialize config")?;
let path = xdg_dirs
.place_config_file(CONFIG_FILE_NAME)
.context("unable to create config directory")?;
spawn(async {
tokio::spawn(async {
let res = tokio::fs::write(path, conf)
.await
.context("unable to write config file");
-98
View File
@@ -1,98 +0,0 @@
use std::sync::Arc;
use slint::ComponentHandle;
use tokio::sync::mpsc;
use crate::{runtime::spawn, ui};
/// A thing for spawning I/O-tasks and updating the GUI `ConnectionStatus` with the result.
#[derive(Clone)]
pub struct Io {
state: Arc<State>,
}
struct State {
status: mpsc::Sender<ui::ConnectionStatus>,
}
impl Io {
pub fn new(app: &ui::App) -> Self {
let (tx, mut rx) = mpsc::channel(10);
let app_weak = app.as_weak();
spawn(async move {
loop {
let Some(status) = rx.recv().await else {
return;
};
let _ = app_weak.upgrade_in_event_loop(move |app| {
app.global::<ui::State>().set_connection_status(status);
});
}
});
Io {
state: Arc::new(State { status: tx }),
}
}
/// Spawn a fallible async function.
///
/// Update the `ConnectionStatus` in the GUI depending on whether the result is an error.
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn<Fut>(&self, f: impl FnOnce() -> Fut + Send + 'static)
where
Fut: Future<Output = anyhow::Result<()>>,
Fut: Send + 'static,
{
let io = self.clone();
spawn(async move {
let _ = io.state.status.send(ui::ConnectionStatus::Syncing).await;
let result = f().await;
let was_error = result.is_err();
if let Err(e) = result {
eprintln!("{e:?}");
}
let _ = io
.state
.status
.send(if was_error {
ui::ConnectionStatus::Error
} else {
ui::ConnectionStatus::Idle
})
.await;
});
}
/// Spawn a fallible async function.
///
/// Update the `ConnectionStatus` in the GUI depending on whether the result is an error.
// FIXME: this function is duplicated from the one above, but without the `Send` requirement on the future.
#[cfg(target_arch = "wasm32")]
pub fn spawn<Fut>(&self, f: impl FnOnce() -> Fut + Send + 'static)
where
Fut: Future<Output = anyhow::Result<()>>,
Fut: 'static,
{
let io = self.clone();
spawn(async move {
let _ = io.state.status.send(ui::ConnectionStatus::Syncing).await;
let result = f().await;
let was_error = result.is_err();
if let Err(e) = result {
eprintln!("{e:?}");
}
let _ = io
.state
.status
.send(if was_error {
ui::ConnectionStatus::Error
} else {
ui::ConnectionStatus::Idle
})
.await;
});
}
}
+71 -114
View File
@@ -2,7 +2,6 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod config;
mod io;
mod runtime;
#[cfg(target_os = "android")]
@@ -16,16 +15,13 @@ use std::{
sync::{Arc, Mutex},
};
use inkopslista_lib::{ButtonItem, Item, ItemData};
use inkopslista_lib::{Item, ItemData};
use reqwest::Url;
use slint::{ComponentHandle, Model, ModelRc, ToSharedString, VecModel};
use slint::{Model, ModelRc, ToSharedString, VecModel};
use crate::{config::Config, io::Io};
use crate::{config::Config, runtime::spawn};
/// Exported slint types
mod ui {
slint::include_modules!();
}
slint::include_modules!();
/// GET "/api/list" from the server
async fn get_list(conf: &Mutex<Config>) -> Result<Vec<Item>, anyhow::Error> {
@@ -35,13 +31,6 @@ async fn get_list(conf: &Mutex<Config>) -> Result<Vec<Item>, anyhow::Error> {
Ok(list)
}
async fn get_favorites(conf: &Mutex<Config>) -> Result<Vec<ButtonItem>, anyhow::Error> {
let base = Url::parse(&conf.lock().unwrap().address)?;
let url = base.join("/api/favorites")?;
let favs = reqwest::get(url).await?.json::<Vec<ButtonItem>>().await?;
Ok(favs)
}
/// Add or update an item in the list
async fn put_list(item: &str, data: &ItemData, conf: &Mutex<Config>) -> Result<(), anyhow::Error> {
let client = reqwest::Client::new();
@@ -64,32 +53,12 @@ async fn delete_item(item: &str, conf: &Mutex<Config>) -> Result<(), anyhow::Err
Ok(())
}
async fn put_category(
itemname: &str,
category: &str,
conf: &Mutex<Config>,
) -> Result<(), anyhow::Error> {
let client = reqwest::Client::new();
let url: Url = format!(
"{address}/api/favorites/{itemname}/category",
address = conf.lock().unwrap().address
)
.parse()?;
let _response = client
.put(url)
.body(category.to_string())
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Convert shopping list to slint types
fn update_slint_list(new_list: Vec<Item>, list: &VecModel<ui::SItem>) {
fn update_slint_list(new_list: Vec<Item>, list: &VecModel<SItem>) {
list.clear();
new_list
.iter()
.map(|item| ui::SItem {
.map(|item| SItem {
name: item.name.to_shared_string(),
checked: item.data.checked,
amount: item.data.amount as i32,
@@ -97,47 +66,30 @@ fn update_slint_list(new_list: Vec<Item>, list: &VecModel<ui::SItem>) {
.for_each(|item| list.push(item));
}
fn update_slint_favs(new_favs: Vec<ButtonItem>, favs: &VecModel<ui::SButtonItem>) {
favs.clear();
new_favs
.into_iter()
.map(|item| ui::SButtonItem {
name: item.name.into(),
category: item.category.unwrap_or_default().to_shared_string(),
})
.for_each(|item| favs.push(item));
}
fn refresh_list(conf: &Arc<Mutex<Config>>, app_weak: &slint::Weak<ui::App>, io: &Io) {
fn refresh_list(conf: &Arc<Mutex<Config>>, app_weak: &slint::Weak<App>) {
let conf = conf.clone();
let app_weak = app_weak.clone();
io.spawn(async move || {
let new_list = get_list(&conf).await?;
spawn(async move {
let new_list = match get_list(&conf).await {
Ok(new_list) => new_list,
Err(e) => {
eprintln!("{e:?}");
return;
}
};
let _ = app_weak.upgrade_in_event_loop(|app| {
let list: ModelRc<ui::SItem> = app.global::<ui::State>().get_list();
let list: &VecModel<ui::SItem> =
list.as_any().downcast_ref().expect("list is a VecModel");
update_slint_list(new_list, list);
let list: ModelRc<SItem> = app.global::<State>().get_list();
let list: &VecModel<SItem> = list.as_any().downcast_ref().expect("list is a VecModel");
update_slint_list(new_list, &list);
});
let new_favs = get_favorites(&conf).await?;
let _ = app_weak.upgrade_in_event_loop(|app| {
let favs: ModelRc<ui::SButtonItem> = app.global::<ui::State>().get_favorites();
let favs: &VecModel<ui::SButtonItem> =
favs.as_any().downcast_ref().expect("favs is a vec model");
update_slint_favs(new_favs, favs);
});
Ok(())
});
}
pub fn run() -> Result<(), anyhow::Error> {
let app = ui::App::new()?;
let io_ = Io::new(&app);
let state = app.global::<ui::State>();
let app = App::new()?;
let state = app.global::<State>();
let list_shared = Rc::new(VecModel::default());
let fav_shared = Rc::new(VecModel::default());
state.set_list(ModelRc::new(list_shared.clone()));
state.set_favorites(ModelRc::new(fav_shared.clone()));
#[cfg(target_os = "linux")]
let conf = runtime::RT
@@ -163,24 +115,22 @@ pub fn run() -> Result<(), anyhow::Error> {
let conf = conf_shared.clone();
let app_weak = app.as_weak();
// load shopping list from server on start
refresh_list(&conf, &app_weak, &io_);
let io = io_.clone();
refresh_list(&conf, &app_weak);
state.on_refresh_list(move || {
refresh_list(&conf, &app_weak, &io);
refresh_list(&conf, &app_weak);
});
let conf = conf_shared.clone();
let list = list_shared.clone();
let io = io_.clone();
state.on_add_to_list(move |item| {
if let Some(index) = list.iter().position(|item2| item2.name == item) {
let mut item2 = list.row_data(index).unwrap();
item2.amount += 1;
item2.amount = item2.amount + 1;
let item3 = item2.clone();
list.set_row_data(index, item2);
let conf = conf.clone();
io.spawn(async move || {
put_list(
spawn(async move {
let res = put_list(
&item,
&ItemData {
checked: item3.checked,
@@ -188,19 +138,22 @@ pub fn run() -> Result<(), anyhow::Error> {
},
&conf,
)
.await
.await;
if let Err(err) = res {
eprintln!("{err:?}");
}
});
return;
}
list.push(ui::SItem {
list.push(SItem {
checked: false,
name: item.clone(),
amount: 1,
});
let conf = conf.clone();
io.spawn(async move || {
spawn(async move {
// TODO: add number to increment
put_list(
let res = put_list(
&item,
&ItemData {
checked: false,
@@ -208,27 +161,30 @@ pub fn run() -> Result<(), anyhow::Error> {
},
&conf,
)
.await
.await;
if let Err(err) = res {
eprintln!("{err:?}");
}
});
});
let conf = conf_shared.clone();
let list = list_shared.clone();
let io = io_.clone();
state.on_check_item(move |item| {
let positem = list
.iter()
.enumerate()
.find(|(_, item2)| item2.name == item);
let Some((pos, mut item)) = positem else {
return;
let (pos, mut item) = match positem {
Some(positem) => positem,
None => return,
};
item.checked = !item.checked;
list.set_row_data(pos, item.clone());
let conf = conf.clone();
io.spawn(async move || {
put_list(
spawn(async move {
let res = put_list(
&item.name,
&ItemData {
checked: item.checked,
@@ -236,13 +192,15 @@ pub fn run() -> Result<(), anyhow::Error> {
},
&conf,
)
.await
.await;
if let Err(err) = res {
eprintln!("{err:?}");
}
});
});
let conf = conf_shared.clone();
let list = list_shared.clone();
let io = io_.clone();
state.on_delete_checked(move || {
let poss: Vec<_> = list
.iter()
@@ -256,30 +214,27 @@ pub fn run() -> Result<(), anyhow::Error> {
let conf = conf.clone();
io.spawn(async move || {
let mut result = Ok(());
spawn(async move {
for (_, name) in poss {
let r = delete_item(&name, &conf).await;
if r.is_err() {
result = r;
let res = delete_item(&name, &conf).await;
if let Err(err) = res {
eprintln!("{err:?}");
}
}
result
});
});
let conf = conf_shared.clone();
let list = list_shared.clone();
let io = io_.clone();
state.on_inc_amount(move |item| {
if let Some(index) = list.iter().position(|item2| item2.name == item) {
let mut item2 = list.row_data(index).unwrap();
item2.amount += 1;
item2.amount = item2.amount + 1;
let item3 = item2.clone();
list.set_row_data(index, item2);
let conf = conf.clone();
io.spawn(async move || {
put_list(
spawn(async move {
let res = put_list(
&item,
&ItemData {
checked: item3.checked,
@@ -287,28 +242,36 @@ pub fn run() -> Result<(), anyhow::Error> {
},
&conf,
)
.await
.await;
if let Err(err) = res {
eprintln!("{err:?}");
}
});
return;
}
});
let conf = conf_shared.clone();
let list = list_shared.clone();
let io = io_.clone();
state.on_dec_amount(move |item| {
if let Some(index) = list.iter().position(|item2| item2.name == item) {
let mut item2 = list.row_data(index).unwrap();
item2.amount -= 1;
item2.amount = item2.amount - 1;
let item3 = item2.clone();
list.set_row_data(index, item2);
let conf = conf.clone();
if item3.amount < 1 {
list.remove(index);
io.spawn(async move || delete_item(&item3.name, &conf).await);
spawn(async move {
let res = delete_item(&item3.name, &conf).await;
if let Err(err) = res {
eprintln!("{err:?}");
}
});
} else {
io.spawn(async move || {
spawn(async move {
// TODO: add number to increment
put_list(
let res = put_list(
&item,
&ItemData {
checked: item3.checked,
@@ -316,22 +279,16 @@ pub fn run() -> Result<(), anyhow::Error> {
},
&conf,
)
.await
.await;
if let Err(err) = res {
eprintln!("{err:?}");
}
});
return;
}
}
});
let conf = conf_shared.clone();
let io = io_.clone();
state.on_set_cat(move |itemname, cat| {
let conf = conf.clone();
io.spawn(async move || put_category(&itemname, &cat, &conf).await);
return;
});
state.on_set_server_address(move |address| {
conf_shared.lock().unwrap().address = address.to_string();
#[cfg(target_os = "linux")]
-1
View File
@@ -1 +0,0 @@
+20 -83
View File
@@ -7,17 +7,15 @@ import {
CheckBox,
ScrollView,
GridBox,
HorizontalBox,
ComboBox,
Palette, Spinner, SpinBox,
} from "std-widgets.slint";
import { SettingsView } from "settings.slint";
import { ConnectionStatus, State, SItem, SButtonItem } from "state.slint";
export { ConnectionStatus, State, SItem }
import { State, SItem } from "state.slint";
export { State, SItem }
component GoodListItem inherits Rectangle {
in-out property <SItem> item;
background: Palette.alternate-background;
background: #ffffff10;
border-radius: 8pt;
border-width: 10pt;
HorizontalLayout {
@@ -112,7 +110,7 @@ component PlusMinus inherits HorizontalLayout {
component GoodListItemAdd inherits Rectangle {
in-out property <SItem> item;
background: Palette.alternate-background;
background: #ffffff10;
border-radius: 8pt;
border-width: 10pt;
HorizontalLayout {
@@ -179,53 +177,30 @@ component GoodsListAdd {
}
component GoodButtons inherits ScrollView {
in property <length> button-min-width: 100pt;
in property <length> button-height: 60pt;
in property <length> button-spacing: 10pt;
in property <[string]> favorites: ["Mjölk", "Ost", "Ägg", "Bullens", "Oboy", "Mjöl", "Havregryn", "KrosTom"];
in property <int> button-width: 100;
in property <int> button-height: 60;
in property <int> button-spacing: 10;
property <int> count-x: floor(root.width / (button-min-width + button-spacing));
property <int> count-y: ceil(State.favorites.length / count-x);
property <length> button-width: ((self.width + button-spacing ) / count-x) - button-spacing;
property <int> count-x: floor(root.width / 1pt / (button-width + button-spacing));
property <int> count-y: ceil(favorites.length / count-x);
viewport-height: (count-y * (button-height + button-spacing));
viewport-height: (count-y * (button-height + button-spacing)) * 1pt;
mouse-drag-pan-enabled: true;
for item[idx] in State.favorites: Button {
x: floor(mod(idx, count-x)) * (root.button-width + root.button-spacing);
y: floor(idx / count-x) * (root.button-height + root.button-spacing);
width: root.button-width;
height: root.button-height;
text: item.name;
for item[idx] in favorites: Button {
x: floor(mod(idx, count-x)) * (root.button-width + root.button-spacing) * 1pt;
y: floor(idx / count-x) * (root.button-height + root.button-spacing) * 1pt;
width: root.button-width * 1pt;
height: root.button-height * 1pt;
text: item;
clicked => {
State.add-to-list(item.name);
State.add-to-list(item);
}
}
}
component ConnStatusIndicator inherits Rectangle {
width: 18px;
Image {
states [
idle when State.connection-status == ConnectionStatus.Idle: {
opacity: 0%;
}
syncing when State.connection-status == ConnectionStatus.Syncing: {
colorize: green;
opacity: 75% + 25% * sin(360deg * animation-tick() / 1s);
}
error when State.connection-status == ConnectionStatus.Error: {
colorize: red;
opacity: 100%;
}
]
source: @image-url("../images/arrow-down-up.svg");
width: 24px;
height: self.width;
}
}
component RefreshButton inherits HorizontalLayout {
spacing: 8px;
alignment: center;
Button {
text: "Refresh";
@@ -234,7 +209,6 @@ component RefreshButton inherits HorizontalLayout {
State.refresh-list();
}
}
ConnStatusIndicator { }
}
component AddView inherits VerticalLayout {
@@ -243,7 +217,7 @@ component AddView inherits VerticalLayout {
padding: 8px;
spacing: 8px;
RefreshButton {}
RefreshButton { }
goods := GoodsListAdd {
items <=> list;
@@ -304,40 +278,10 @@ component ShopView inherits VerticalLayout {
}
}
component CatItem inherits HorizontalBox {
in-out property <SButtonItem> item;
Text {
text: item.name;
vertical-alignment: center;
font-size: 16px;
}
ComboBox {
model: ["kyl", "skafferi", "frys", "övrigt"];
current-value: item.category;
width: 60%;
selected(category) => { State.set-cat(item.name, category) }
}
}
component CatView inherits VerticalLayout {
in-out property <[SButtonItem]> cats;
for item in cats: CatItem {
item: item;
}
}
export component App inherits Window {
title: "Inköpslista";
icon: @image-url("../images/icon.svg");
preferred-height: 700px;
preferred-width: 480px;
TabWidget {
Tab {
title: "Add";
@@ -353,13 +297,6 @@ export component App inherits Window {
}
}
Tab {
title: "Categories";
CatView {
cats <=> State.favorites;
}
}
Tab {
title: "Settings";
SettingsView { }
-18
View File
@@ -4,17 +4,6 @@ export struct SItem {
amount: int,
}
export struct SButtonItem{
name: string,
category: string,
}
export enum ConnectionStatus {
Idle,
Syncing,
Error,
}
export global State {
in-out property <[SItem]> list: [
{ name: "Test 1", checked: true },
@@ -27,16 +16,9 @@ export global State {
callback delete-checked();
callback inc-amount(string);
callback dec-amount(string);
callback set-cat(string, string);
in-out property <string> server-address;
in property <[SButtonItem]> favorites: [
{ name: "mjölk", category: "kyl" },
{ name: "ost", category: "kyl" },
];
callback set-server-address(string);
set-server-address(address) => {
server-address = address;
}
in-out property <ConnectionStatus> connection-status: ConnectionStatus.Syncing;
}
+2 -6
View File
@@ -7,13 +7,9 @@ pub struct Item {
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct ItemData {
pub checked: bool,
pub amount: i64,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct ButtonItem {
pub name: String,
pub category: Option<String>,
// TODO: add number
}
+2 -8
View File
@@ -1,17 +1,11 @@
[package]
name = "inkopslista-srv"
version = "0.1.0"
edition = "2024"
edition = "2021"
[dependencies]
rocket = { version = "0.5.1", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
inkopslista-lib = { path = "../lib" }
sqlite = "0.37.0"
clap = { version = "4.6.6", features = ["derive", "env"] }
axum = { version = "0.8.9", features = ["http1", "http2", "tokio"] }
tower-http = { version = "0.7.0", features = ["fs", "trace", "compression-gzip"] }
tokio = { version = "1.53.1", features = ["full"] }
rmcp = { version = "3.1.2", features = ["server", "macros", "schemars", "transport-streamable-http-server", "transport-streamable-http-server-session"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+1 -11
View File
@@ -3,14 +3,4 @@ CREATE TABLE
itemname TEXT NOT NULL PRIMARY KEY,
checked INT NOT NULL, -- actually a boolean
amount INT NOT NULL
) STRICT;
CREATE TABLE
IF NOT EXISTS adds (
itemname TEXT NOT NULL,
date_added INT NOT NULL,
PRIMARY KEY (itemname, date_added)
) STRICT;
CREATE TABLE
IF NOT EXISTS categories (itemname TEXT NOT NULL PRIMARY KEY, category TEXT) STRICT;
) STRICT;
+41 -114
View File
@@ -1,29 +1,32 @@
use axum::extract::{Path, State};
use axum::response::Html;
use axum::routing::method_routing::{get, put};
use axum::{Json, Router};
use clap::Parser;
use inkopslista_lib::{ButtonItem, Item, ItemData};
use sqlite::ConnectionThreadSafe;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing::Level;
#[macro_use]
extern crate rocket;
mod mcp;
use std::{env, path::Path};
use inkopslista_lib::{Item, ItemData};
use rocket::{
fs::{FileServer, NamedFile},
serde::json::Json,
shield::Shield,
State,
};
use sqlite::ConnectionThreadSafe;
/// The directory where the web files for the gui is stored.
fn www_path() -> &'static std::path::Path {
std::path::Path::new("./www")
fn www_path() -> &'static Path {
Path::new("./www")
}
async fn index() -> Html<&'static str> {
Html(include_str!("../../gui/index.html"))
/// Serve `index.html` from [`www_path`].
#[get("/")]
async fn index() -> NamedFile {
NamedFile::open(www_path().join("index.html"))
.await
.expect("index.html")
}
async fn get_list(State(db): State<Arc<ConnectionThreadSafe>>) -> Json<Vec<Item>> {
#[get("/api/list")]
fn get_list(db: &State<ConnectionThreadSafe>) -> Json<Vec<Item>> {
// TODO errorhandling
let query = "SELECT * FROM list;";
let list = db
@@ -44,119 +47,43 @@ async fn get_list(State(db): State<Arc<ConnectionThreadSafe>>) -> Json<Vec<Item>
Json(list)
}
async fn get_favorites(State(db): State<Arc<ConnectionThreadSafe>>) -> Json<Vec<ButtonItem>> {
let opt = Opt::parse();
let query = format!("WITH favorites as (SELECT itemname, COUNT(date_added) day_count
FROM adds
WHERE (date(julianday('now')) - date(date_added, 'unixepoch')) < 184
GROUP BY itemname
HAVING day_count > {num_days})
SELECT favorites.itemname, category FROM favorites LEFT OUTER JOIN categories ON favorites.itemname = categories.itemname", num_days = opt.num_days);
let list: Vec<ButtonItem> = db
.prepare(query)
.unwrap()
.into_iter()
.map(|row| {
let row = row.unwrap();
ButtonItem {
name: row.read::<&str, _>("itemname").to_string(),
category: row
.try_read::<&str, _>("category")
.ok()
.map(ToString::to_string),
}
})
.collect();
Json(list)
}
async fn put_category(
State(db): State<Arc<ConnectionThreadSafe>>,
Path(item): Path<String>,
category: String,
) {
let query = "INSERT INTO categories VALUES (?, ?) ON CONFLICT DO UPDATE SET category = ?";
let mut statement = db.prepare(query).unwrap();
statement.bind((1, item.as_str())).unwrap();
statement.bind((2, category.as_str())).unwrap();
statement.bind((3, category.as_str())).unwrap();
while let Ok(sqlite::State::Row) = statement.next() {}
}
async fn put_list(
State(db): State<Arc<ConnectionThreadSafe>>,
Path(item): Path<String>,
Json(data): Json<ItemData>,
) {
#[put("/api/list/<item>", data = "<data>")]
fn put_list(item: &str, data: Json<ItemData>, db: &State<ConnectionThreadSafe>) {
// TODO: errorhandling
// TODO: transaction
let query =
"INSERT INTO list VALUES(?, ?, ?) ON CONFLICT DO UPDATE SET checked = ?, amount = ?;";
let mut statement = db.prepare(query).unwrap();
statement.bind((1, item.as_str())).unwrap();
let data = data.into_inner();
statement.bind((1, item)).unwrap();
statement.bind((2, data.checked as i64)).unwrap();
statement.bind((3, data.amount)).unwrap();
statement.bind((4, data.checked as i64)).unwrap();
statement.bind((5, data.amount)).unwrap();
while let Ok(sqlite::State::Row) = statement.next() {}
let query = "INSERT INTO adds VALUES(?, strftime('%s','now')) ON CONFLICT DO NOTHING";
let mut statement = db.prepare(query).unwrap();
statement.bind((1, item.as_str())).unwrap();
while let Ok(sqlite::State::Row) = statement.next() {}
}
async fn delete_item(State(db): State<Arc<ConnectionThreadSafe>>, Path(item): Path<String>) {
#[delete("/api/list/<item>")]
fn delete_item(item: &str, db: &State<ConnectionThreadSafe>) {
// TODO: Eror handling
let query = "DELETE FROM list WHERE itemname = ?";
let mut statement = db.prepare(query).unwrap();
statement.bind((1, item.as_str())).unwrap();
statement.bind((1, item)).unwrap();
while let Ok(sqlite::State::Row) = statement.next() {}
}
#[derive(Parser)]
struct Opt {
#[clap(long, env = "BIND", default_value = "127.0.0.1:8000")]
bind: SocketAddr,
#[clap(long, env = "DB_PATH")]
db_path: PathBuf,
#[clap(long, env = "RUST_LOG", default_value = "debug")]
log_level: String,
#[clap(long, env = "NUM_DAYS", default_value = "5")]
num_days: u32,
}
#[tokio::main]
async fn main() {
let opt = Opt::parse();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(&opt.log_level))
.init();
let db = sqlite::Connection::open_thread_safe(opt.db_path).unwrap();
#[launch]
fn rocket() -> _ {
let db_path = env::var("DB_PATH").unwrap();
let db = sqlite::Connection::open_thread_safe(db_path).unwrap();
db.execute(include_str!("db/init.sql"))
.expect("unable to initialize database");
let db = Arc::new(db);
let app = Router::new()
.route("/", get(index))
.route("/api/list", get(get_list))
.route("/api/list/{item}", put(put_list).delete(delete_item))
.route("/api/favorites", get(get_favorites))
.route("/api/favorites/{item}/category", put(put_category))
.fallback_service(ServeDir::new(www_path()))
.nest_service("/mcp", mcp::service(db.clone()))
.layer(
TraceLayer::new_for_http()
.make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO))
.on_request(tower_http::trace::DefaultOnRequest::new().level(Level::INFO))
.on_response(tower_http::trace::DefaultOnResponse::new().level(Level::INFO)),
rocket::build()
.attach(
Shield::default()
// Allow content to be served through iframes
.disable::<rocket::shield::Frame>(),
)
.with_state(db);
let listener = tokio::net::TcpListener::bind(opt.bind).await.unwrap();
tracing::info!("Listening on http://{}", opt.bind);
axum::serve(listener, app).await.unwrap();
.manage(db)
.mount("/", routes![index, get_list, put_list, delete_item])
.mount("/", FileServer::from(www_path()))
}
-89
View File
@@ -1,89 +0,0 @@
//! MCP routes
use axum::Json;
use axum::extract::{Path, State};
use inkopslista_lib::ItemData;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::tower::{
StreamableHttpServerConfig, StreamableHttpService,
};
use rmcp::{schemars, tool, tool_router};
use serde::Deserialize;
use sqlite::ConnectionThreadSafe;
use std::fmt::Write;
use std::sync::Arc;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct PutParams {
/// The name of the shopping-list item,
item: String,
/// The number of items to buy.
#[serde(default = "default_amount")]
amount: i64,
/// Whether the item has been checked off.
#[serde(default)]
checked: bool,
}
fn default_amount() -> i64 {
1
}
#[derive(Clone)]
pub struct McpServer {
db: Arc<ConnectionThreadSafe>,
}
#[tool_router(server_handler)]
impl McpServer {
#[tool(description = "Read the entire shopping list")]
async fn list(&self) -> String {
// TODO: don't call the HTTP request handlers. Split the DB stuff out instead.
let db = State(self.db.clone());
let items = crate::get_list(db).await.0;
let mut out = String::new();
for item in items {
_ = writeln!(
&mut out,
"- [{checked}] {n}x {name}",
checked = if item.data.checked { "x" } else { " " },
n = item.data.amount,
name = item.name,
);
}
out
}
#[tool(description = "Put an item onto the shopping list.")]
async fn put(&self, Parameters(params): Parameters<PutParams>) -> String {
// TODO: don't call the HTTP request handlers. Split the DB stuff out instead.
let db = State(self.db.clone());
let data = ItemData {
amount: params.amount,
checked: params.checked,
};
crate::put_list(db, Path(params.item), Json(data)).await;
"Ok".to_string()
}
}
/// Create an MCP service that can be plugged into axum
pub fn service(
db: Arc<ConnectionThreadSafe>,
) -> StreamableHttpService<McpServer, LocalSessionManager> {
// TODO: tower_http::auth::add_authorization
let config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(true)
.with_json_response(false)
.disable_allowed_hosts()
.disable_allowed_origins();
let server = McpServer { db };
StreamableHttpService::new(
move || Ok(server.clone()),
LocalSessionManager::default().into(),
config,
)
}