This commit is contained in:
Jonas Heinrich 2026-03-10 23:25:26 +01:00
parent f810a0f645
commit 3490cd6ed9
7 changed files with 2799 additions and 0 deletions

5
.gitignore vendored
View file

@ -6,3 +6,8 @@
/captures
.externalNativeBuild
.idea/*
# Added by cargo
/target

2068
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

11
Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "next-companion"
version = "0.1.0"
edition = "2021"
[dependencies]
gtk = { package = "gtk4", version = "0.10" }
adw = { package = "libadwaita", version = "0.8", features = ["v1_6"] }
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
serde_json = "1"
dirs = "5"

62
flake.lock generated Normal file
View file

@ -0,0 +1,62 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1773115373,
"narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

47
flake.nix Normal file
View file

@ -0,0 +1,47 @@
{
description = "NextCompanion a minimal GTK4 Nextbike client for Linux";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, rust-overlay, ... }:
let
system = "x86_64-linux";
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
runtimeDeps = with pkgs; [
gtk4
libadwaita
glib
];
buildDeps = with pkgs; [
pkg-config
rust-bin.stable.latest.default
];
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = buildDeps ++ runtimeDeps;
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeDeps}:$LD_LIBRARY_PATH"
'';
};
packages.${system}.default = pkgs.rustPlatform.buildRustPackage {
pname = "next-companion";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = buildDeps;
buildInputs = runtimeDeps;
};
apps.${system}.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/next-companion";
};
};
}

1
result Symbolic link
View file

@ -0,0 +1 @@
/nix/store/gan72x7dji7c6lwngwfx88my14azg9wn-next-companion-0.1.0

605
src/main.rs Normal file
View file

@ -0,0 +1,605 @@
use adw::prelude::*;
use adw::{Application, ApplicationWindow, BottomSheet, Clamp, HeaderBar, NavigationPage, NavigationView};
use gtk::{
Box, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Spinner, Stack,
};
use gtk::gio;
use gtk::glib;
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
const API_KEY: &str = "3IaBlP9OZw14dvES";
const BASE_URL: &str = "https://api.nextbike.net";
// ── Bike model ────────────────────────────────────────────────────────────────
#[derive(Clone)]
struct Bike {
id: String,
code: String,
electric_lock: bool,
}
// ── Persistent login key ──────────────────────────────────────────────────────
fn config_path() -> PathBuf {
let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
p.push("next-companion");
p.push("loginkey");
p
}
fn load_loginkey() -> Option<String> {
fs::read_to_string(config_path())
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn save_loginkey(key: &str) {
let path = config_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&path, key);
}
fn clear_loginkey() {
let _ = fs::remove_file(config_path());
}
// ── API calls (blocking — run via gio::spawn_blocking) ────────────────────────
fn api_login(phone: &str, pin: &str) -> Result<String, String> {
let resp = reqwest::blocking::Client::new()
.post(format!("{BASE_URL}/api/login.json"))
.form(&[("apikey", API_KEY), ("mobile", phone), ("pin", pin)])
.send()
.map_err(|e| e.to_string())?;
let json: serde_json::Value =
serde_json::from_str(&resp.text().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
json["user"]["loginkey"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "Invalid credentials".to_string())
}
fn api_get_rentals(loginkey: &str) -> Result<Vec<Bike>, String> {
let resp = reqwest::blocking::Client::new()
.post(format!("{BASE_URL}/api/getOpenRentals.json"))
.form(&[("apikey", API_KEY), ("loginkey", loginkey)])
.send()
.map_err(|e| e.to_string())?;
let json: serde_json::Value =
serde_json::from_str(&resp.text().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let arr = json["rentalCollection"]
.as_array()
.ok_or_else(|| "No rental data".to_string())?;
Ok(arr
.iter()
.map(|b| Bike {
id: b["bike"].as_str().unwrap_or("").to_string(),
code: b["code"].as_str().unwrap_or("").to_string(),
electric_lock: b["electric_lock"].as_str().map_or(false, |s| s == "true"),
})
.collect())
}
fn api_rent(loginkey: &str, bike_id: &str) -> Result<(), String> {
reqwest::blocking::Client::new()
.post(format!("{BASE_URL}/api/rent.json"))
.form(&[("apikey", API_KEY), ("loginkey", loginkey), ("bike", bike_id)])
.send()
.map_err(|e| e.to_string())?;
Ok(())
}
fn api_return(loginkey: &str, bike_id: &str, station_id: &str) -> Result<(), String> {
reqwest::blocking::Client::new()
.post(format!("{BASE_URL}/api/return.json"))
.form(&[
("apikey", API_KEY),
("bike", bike_id),
("loginkey", loginkey),
("station", station_id),
("comment", ""),
])
.send()
.map_err(|e| e.to_string())?;
Ok(())
}
// ── Async helpers (run on GLib main context) ──────────────────────────────────
async fn load_rentals(key: String, bikes: Rc<RefCell<Vec<Bike>>>, bikes_list: ListBox, list_stack: Stack) {
let result = match gio::spawn_blocking(move || api_get_rentals(&key)).await {
Ok(r) => r,
Err(_) => return,
};
if let Ok(new_bikes) = result {
while let Some(child) = bikes_list.first_child() {
bikes_list.remove(&child);
}
for bike in &new_bikes {
let text = format!(
"Bike {} · code: {}{}",
bike.id,
bike.code,
if bike.electric_lock { "" } else { "" }
);
let lbl = Label::builder()
.label(&text)
.xalign(0.0)
.margin_top(14)
.margin_bottom(14)
.margin_start(12)
.margin_end(12)
.build();
let row = ListBoxRow::new();
row.set_child(Some(&lbl));
bikes_list.append(&row);
}
list_stack.set_visible_child_name(if new_bikes.is_empty() { "empty" } else { "list" });
*bikes.borrow_mut() = new_bikes;
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main() -> glib::ExitCode {
let app = Application::builder()
.application_id("org.nextbike.NextCompanion")
.build();
app.connect_activate(build_ui);
app.run()
}
// ── UI ────────────────────────────────────────────────────────────────────────
fn build_ui(app: &Application) {
let loginkey: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(load_loginkey()));
let bikes: Rc<RefCell<Vec<Bike>>> = Rc::new(RefCell::new(vec![]));
let return_bike: Rc<RefCell<Option<Bike>>> = Rc::new(RefCell::new(None));
// ── Login page ────────────────────────────────────────────────────────────
let phone_entry = Entry::builder()
.placeholder_text("Phone number")
.build();
let pin_entry = Entry::builder()
.placeholder_text("PIN")
.visibility(false)
.build();
let login_err = Label::builder()
.css_classes(["error"])
.wrap(true)
.visible(false)
.build();
let login_btn = Button::builder()
.label("Sign In")
.css_classes(["suggested-action", "pill"])
.build();
let login_spinner = Spinner::new();
let login_form = Box::builder()
.orientation(Orientation::Vertical)
.spacing(12)
.margin_top(24).margin_bottom(24).margin_start(12).margin_end(12)
.build();
login_form.append(&phone_entry);
login_form.append(&pin_entry);
login_form.append(&login_err);
login_form.append(&login_btn);
login_form.append(&login_spinner);
let login_clamp = Clamp::builder().maximum_size(400).build();
login_clamp.set_child(Some(&login_form));
let login_body = Box::builder().orientation(Orientation::Vertical).build();
login_body.append(&HeaderBar::new());
login_body.append(&login_clamp);
let login_page = NavigationPage::builder()
.title("Sign In")
.child(&login_body)
.build();
// ── Main page ─────────────────────────────────────────────────────────────
let bikes_list = ListBox::builder()
.css_classes(["boxed-list"])
.selection_mode(gtk::SelectionMode::None)
.margin_top(8).margin_bottom(8).margin_start(12).margin_end(12)
.build();
let empty_label = Label::builder()
.label("No active rentals")
.margin_top(48)
.css_classes(["dim-label"])
.build();
let list_stack = Stack::new();
list_stack.add_named(&empty_label, Some("empty"));
list_stack.add_named(&bikes_list, Some("list"));
list_stack.set_visible_child_name("empty");
let scroll = ScrolledWindow::builder().vexpand(true).child(&list_stack).build();
let rent_btn = Button::builder()
.label("Rent a Bike")
.css_classes(["suggested-action", "pill"])
.margin_top(8).margin_bottom(12).margin_start(12).margin_end(12)
.build();
let main_hdr = HeaderBar::new();
let logout_btn = Button::builder()
.icon_name("system-log-out-symbolic")
.tooltip_text("Logout")
.build();
let refresh_btn = Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Refresh")
.build();
main_hdr.pack_end(&logout_btn);
main_hdr.pack_start(&refresh_btn);
// ── Return sheet (bottom sheet) ───────────────────────────────────────────
let station_entry = Entry::builder()
.placeholder_text("Station number")
.input_purpose(gtk::InputPurpose::Digits)
.build();
let ret_err = Label::builder()
.css_classes(["error"])
.wrap(true)
.visible(false)
.build();
let ret_submit = Button::builder()
.label("Return Bike")
.css_classes(["destructive-action", "pill"])
.build();
let ret_spinner = Spinner::new();
let electric_msg = Label::builder()
.label("This bike has an electric lock.\nJust close the lock to return it.")
.wrap(true)
.justify(gtk::Justification::Center)
.margin_top(24)
.build();
let manual_form = Box::builder()
.orientation(Orientation::Vertical)
.spacing(12)
.build();
manual_form.append(&station_entry);
manual_form.append(&ret_err);
manual_form.append(&ret_submit);
manual_form.append(&ret_spinner);
let ret_inner = Stack::new();
ret_inner.add_named(&manual_form, Some("manual"));
ret_inner.add_named(&electric_msg, Some("electric"));
let sheet_title = Label::builder()
.css_classes(["title-4"])
.label("Return Bike")
.xalign(0.0)
.build();
let sheet_box = Box::builder()
.orientation(Orientation::Vertical)
.spacing(16)
.margin_top(8)
.margin_bottom(24)
.margin_start(16)
.margin_end(16)
.build();
sheet_box.append(&sheet_title);
sheet_box.append(&ret_inner);
let bottom_sheet = BottomSheet::builder()
.show_drag_handle(true)
.sheet(&sheet_box)
.build();
let main_content = Box::builder().orientation(Orientation::Vertical).build();
main_content.append(&scroll);
main_content.append(&rent_btn);
bottom_sheet.set_content(Some(&main_content));
let main_body = Box::builder().orientation(Orientation::Vertical).build();
main_body.append(&main_hdr);
main_body.append(&bottom_sheet);
let main_page = NavigationPage::builder()
.title("NextCompanion")
.child(&main_body)
.build();
// ── Rent page ─────────────────────────────────────────────────────────────
let bike_entry = Entry::builder()
.placeholder_text("Bike number")
.input_purpose(gtk::InputPurpose::Digits)
.build();
let rent_err = Label::builder()
.css_classes(["error"])
.wrap(true)
.visible(false)
.build();
let rent_submit = Button::builder()
.label("Rent")
.css_classes(["suggested-action", "pill"])
.build();
let rent_spinner = Spinner::new();
let rent_form = Box::builder()
.orientation(Orientation::Vertical)
.spacing(12)
.margin_top(24).margin_bottom(24).margin_start(12).margin_end(12)
.build();
rent_form.append(&bike_entry);
rent_form.append(&rent_err);
rent_form.append(&rent_submit);
rent_form.append(&rent_spinner);
let rent_clamp = Clamp::builder().maximum_size(400).build();
rent_clamp.set_child(Some(&rent_form));
let rent_body = Box::builder().orientation(Orientation::Vertical).build();
rent_body.append(&HeaderBar::new());
rent_body.append(&rent_clamp);
let rent_page = NavigationPage::builder()
.title("Rent Bike")
.child(&rent_body)
.build();
// ── Navigation view ───────────────────────────────────────────────────────
let nav = NavigationView::new();
nav.push(&main_page);
if loginkey.borrow().is_none() {
nav.push(&login_page);
}
// ── Window ────────────────────────────────────────────────────────────────
let window = ApplicationWindow::builder()
.application(app)
.title("NextCompanion")
.default_width(390)
.default_height(700)
.content(&nav)
.build();
// ── Login button ──────────────────────────────────────────────────────────
{
let phone = phone_entry.clone();
let pin = pin_entry.clone();
let err = login_err.clone();
let spinner = login_spinner.clone();
let btn = login_btn.clone();
let nav = nav.clone();
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
login_btn.connect_clicked(move |_| {
let p = phone.text().to_string();
let n = pin.text().to_string();
if p.is_empty() || n.is_empty() {
err.set_label("Phone and PIN are required");
err.set_visible(true);
return;
}
err.set_visible(false);
spinner.set_spinning(true);
btn.set_sensitive(false);
let spinner = spinner.clone();
let btn = btn.clone();
let err = err.clone();
let nav = nav.clone();
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || api_login(&p, &n)).await {
Ok(r) => r,
Err(_) => Err("Internal error".to_string()),
};
spinner.set_spinning(false);
btn.set_sensitive(true);
match result {
Ok(key) => {
save_loginkey(&key);
*loginkey.borrow_mut() = Some(key.clone());
nav.pop();
load_rentals(key, bikes, bikes_list, list_stack).await;
}
Err(e) => {
err.set_label(&e);
err.set_visible(true);
}
}
});
});
}
// ── Logout button ─────────────────────────────────────────────────────────
{
let nav = nav.clone();
let login_page = login_page.clone();
let loginkey = loginkey.clone();
logout_btn.connect_clicked(move |_| {
clear_loginkey();
*loginkey.borrow_mut() = None;
nav.push(&login_page);
});
}
// ── Refresh button ────────────────────────────────────────────────────────
{
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
refresh_btn.connect_clicked(move |_| {
if let Some(key) = loginkey.borrow().clone() {
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
glib::MainContext::default().spawn_local(async move {
load_rentals(key, bikes, bikes_list, list_stack).await;
});
}
});
}
// ── Open rent page ────────────────────────────────────────────────────────
{
let nav = nav.clone();
let rent_page = rent_page.clone();
let bike_entry = bike_entry.clone();
rent_btn.connect_clicked(move |_| {
bike_entry.set_text("");
nav.push(&rent_page);
});
}
// ── Rent submit ───────────────────────────────────────────────────────────
{
let loginkey = loginkey.clone();
let entry = bike_entry.clone();
let err = rent_err.clone();
let spinner = rent_spinner.clone();
let btn = rent_submit.clone();
let nav = nav.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
rent_submit.connect_clicked(move |_| {
let id = entry.text().to_string();
if id.is_empty() {
err.set_label("Enter a bike number");
err.set_visible(true);
return;
}
if let Some(key) = loginkey.borrow().clone() {
err.set_visible(false);
spinner.set_spinning(true);
btn.set_sensitive(false);
let spinner = spinner.clone();
let btn = btn.clone();
let err = err.clone();
let nav = nav.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || api_rent(&key, &id)).await {
Ok(r) => r,
Err(_) => Err("Internal error".to_string()),
};
spinner.set_spinning(false);
btn.set_sensitive(true);
if let Err(e) = result {
err.set_label(&e);
err.set_visible(true);
} else {
nav.pop();
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
}
});
}
});
}
// ── Click rental row → open return bottom sheet ───────────────────────────
{
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let return_bike = return_bike.clone();
let ret_inner = ret_inner.clone();
let station_entry = station_entry.clone();
let ret_err = ret_err.clone();
bikes_list.connect_row_activated(move |_, row| {
let idx = row.index() as usize;
let bike = bikes.borrow().get(idx).cloned();
if let Some(bike) = bike {
station_entry.set_text("");
ret_err.set_visible(false);
ret_inner.set_visible_child_name(if bike.electric_lock { "electric" } else { "manual" });
*return_bike.borrow_mut() = Some(bike);
bottom_sheet.set_open(true);
}
});
}
// ── Return submit ─────────────────────────────────────────────────────────
{
let loginkey = loginkey.clone();
let return_bike = return_bike.clone();
let entry = station_entry.clone();
let err = ret_err.clone();
let spinner = ret_spinner.clone();
let btn = ret_submit.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
ret_submit.connect_clicked(move |_| {
let station = entry.text().to_string();
if station.is_empty() {
err.set_label("Enter a station number");
err.set_visible(true);
return;
}
if let (Some(key), Some(bike)) =
(loginkey.borrow().clone(), return_bike.borrow().clone())
{
err.set_visible(false);
spinner.set_spinning(true);
btn.set_sensitive(false);
let spinner = spinner.clone();
let btn = btn.clone();
let err = err.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || {
api_return(&key, &bike.id, &station)
})
.await
{
Ok(r) => r,
Err(_) => Err("Internal error".to_string()),
};
spinner.set_spinning(false);
btn.set_sensitive(true);
if let Err(e) = result {
err.set_label(&e);
err.set_visible(true);
} else {
bottom_sheet.set_open(false);
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
}
});
}
});
}
// ── Initial rentals load ──────────────────────────────────────────────────
if let Some(key) = loginkey.borrow().clone() {
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
glib::MainContext::default().spawn_local(async move {
load_rentals(key, bikes, bikes_list, list_stack).await;
});
}
window.present();
}