init
This commit is contained in:
parent
f810a0f645
commit
3490cd6ed9
7 changed files with 2799 additions and 0 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -6,3 +6,8 @@
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.idea/*
|
.idea/*
|
||||||
|
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
|
|
|
||||||
2068
Cargo.lock
generated
Normal file
2068
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
11
Cargo.toml
Normal file
11
Cargo.toml
Normal 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
62
flake.lock
generated
Normal 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
47
flake.nix
Normal 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
1
result
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/nix/store/gan72x7dji7c6lwngwfx88my14azg9wn-next-companion-0.1.0
|
||||||
605
src/main.rs
Normal file
605
src/main.rs
Normal 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();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue