620 lines
23 KiB
Rust
620 lines
23 KiB
Rust
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);
|
|
|
|
// ── Bottom sheet (rent + return) ──────────────────────────────────────────
|
|
|
|
// — Rent form —
|
|
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_sheet = Box::builder()
|
|
.orientation(Orientation::Vertical)
|
|
.spacing(16)
|
|
.build();
|
|
let rent_sheet_title = Label::builder()
|
|
.css_classes(["title-4"])
|
|
.label("Rent Bike")
|
|
.xalign(0.0)
|
|
.build();
|
|
let rent_form = Box::builder()
|
|
.orientation(Orientation::Vertical)
|
|
.spacing(12)
|
|
.build();
|
|
rent_form.append(&bike_entry);
|
|
rent_form.append(&rent_err);
|
|
rent_form.append(&rent_submit);
|
|
rent_form.append(&rent_spinner);
|
|
rent_sheet.append(&rent_sheet_title);
|
|
rent_sheet.append(&rent_form);
|
|
|
|
// — Return form —
|
|
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 ret_sheet = Box::builder()
|
|
.orientation(Orientation::Vertical)
|
|
.spacing(16)
|
|
.build();
|
|
let ret_sheet_title = Label::builder()
|
|
.css_classes(["title-4"])
|
|
.label("Return Bike")
|
|
.xalign(0.0)
|
|
.build();
|
|
ret_sheet.append(&ret_sheet_title);
|
|
ret_sheet.append(&ret_inner);
|
|
|
|
// — Shared sheet stack —
|
|
let sheet_stack = Stack::new();
|
|
sheet_stack.add_named(&rent_sheet, Some("rent"));
|
|
sheet_stack.add_named(&ret_sheet, Some("return"));
|
|
|
|
let sheet_box = Box::builder()
|
|
.orientation(Orientation::Vertical)
|
|
.margin_top(8)
|
|
.margin_bottom(24)
|
|
.margin_start(16)
|
|
.margin_end(16)
|
|
.build();
|
|
sheet_box.append(&sheet_stack);
|
|
|
|
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();
|
|
|
|
// ── 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 sheet ───────────────────────────────────────────────────────
|
|
{
|
|
let bottom_sheet = bottom_sheet.clone();
|
|
let sheet_stack = sheet_stack.clone();
|
|
let bike_entry = bike_entry.clone();
|
|
let rent_err = rent_err.clone();
|
|
rent_btn.connect_clicked(move |_| {
|
|
bike_entry.set_text("");
|
|
rent_err.set_visible(false);
|
|
sheet_stack.set_visible_child_name("rent");
|
|
bottom_sheet.set_open(true);
|
|
});
|
|
}
|
|
|
|
// ── 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 bottom_sheet = bottom_sheet.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 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_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 {
|
|
bottom_sheet.set_open(false);
|
|
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Click rental row → open return bottom sheet ───────────────────────────
|
|
{
|
|
let bottom_sheet = bottom_sheet.clone();
|
|
let sheet_stack = sheet_stack.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);
|
|
sheet_stack.set_visible_child_name("return");
|
|
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();
|
|
}
|