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 { 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 { 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, 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>>, 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>> = Rc::new(RefCell::new(load_loginkey())); let bikes: Rc>> = Rc::new(RefCell::new(vec![])); let return_bike: Rc>> = 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(); }