add webview map, bike reservation, and desktop entry

- Replace list view with Leaflet.js WebView map with marker clustering
- Add floating action buttons over map (Rent, Show Rentals)
- Click station markers to show bikes with Rent/Reserve buttons
- Add bike type filter menu (All/Standard/E-bikes)
- Support bike reservations via booking API
- Show reserved bikes in rentals overview
- Add desktop entry for app launchers
- Update dependencies: webkit6 0.6, gtk4 0.11, libadwaita 0.9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonas Heinrich 2026-03-27 22:41:24 +01:00
parent 9961a31936
commit 256da4b440
6 changed files with 847 additions and 171 deletions

View file

@ -1,10 +1,13 @@
use adw::prelude::*;
use adw::{Application, ApplicationWindow, BottomSheet, Clamp, HeaderBar, NavigationPage, NavigationView};
use gtk::{
Box, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Spinner, Stack,
Box, Button, Entry, Image, Label, ListBox, ListBoxRow, MenuButton, Orientation,
Overlay, ScrolledWindow, Spinner, Stack,
};
use gtk::gio;
use gtk::glib;
use webkit6::prelude::*;
use webkit6::Settings;
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
@ -20,6 +23,7 @@ struct Bike {
id: String,
code: String,
electric_lock: bool,
is_reserved: bool,
}
// ── Persistent login key ──────────────────────────────────────────────────────
@ -68,7 +72,11 @@ fn api_login(phone: &str, pin: &str) -> Result<String, String> {
}
fn api_get_rentals(loginkey: &str) -> Result<Vec<Bike>, String> {
let resp = reqwest::blocking::Client::new()
let client = reqwest::blocking::Client::new();
let mut bikes = Vec::new();
// Get active rentals
let resp = client
.post(format!("{BASE_URL}/api/getOpenRentals.json"))
.form(&[("apikey", API_KEY), ("loginkey", loginkey)])
.send()
@ -76,17 +84,40 @@ fn api_get_rentals(loginkey: &str) -> Result<Vec<Bike>, 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())
if let Some(arr) = json["rentalCollection"].as_array() {
for b in arr {
bikes.push(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"),
is_reserved: false,
});
}
}
// Get active bookings/reservations
if let Ok(resp) = client
.post(format!("{BASE_URL}/api/v1.1/activeBookings.json"))
.form(&[("apikey", API_KEY), ("loginkey", loginkey)])
.send()
{
if let Ok(text) = resp.text() {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(arr) = json["bookingCollection"].as_array() {
for b in arr {
bikes.push(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"),
is_reserved: true,
});
}
}
}
}
}
Ok(bikes)
}
fn api_rent(loginkey: &str, bike_id: &str) -> Result<(), String> {
@ -98,6 +129,25 @@ fn api_rent(loginkey: &str, bike_id: &str) -> Result<(), String> {
Ok(())
}
fn api_book(loginkey: &str, bike_id: &str) -> Result<(), String> {
let resp = reqwest::blocking::Client::new()
.post(format!("{BASE_URL}/api/v1.1/booking.json"))
.form(&[("apikey", API_KEY), ("loginkey", loginkey), ("bike", bike_id)])
.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())?;
// Check for error in response
if let Some(error) = json["error"].as_str() {
return Err(error.to_string());
}
if json["booking"].is_null() && json["bookingId"].is_null() {
return Err("Reservation failed".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"))
@ -115,39 +165,102 @@ fn api_return(loginkey: &str, bike_id: &str, station_id: &str) -> Result<(), Str
// ── Async helpers (run on GLib main context) ──────────────────────────────────
async fn load_rentals(key: String, bikes: Rc<RefCell<Vec<Bike>>>, bikes_list: ListBox, list_stack: Stack) {
async fn load_rentals(
key: String,
bikes: Rc<RefCell<Vec<Bike>>>,
rentals_list: ListBox,
rentals_btn: Button,
) {
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);
// Clear the rentals list
while let Some(child) = rentals_list.first_child() {
rentals_list.remove(&child);
}
// Count rentals and reservations
let rentals = new_bikes.iter().filter(|b| !b.is_reserved).count();
let reservations = new_bikes.iter().filter(|b| b.is_reserved).count();
let total = new_bikes.len();
// Update button visibility and label
rentals_btn.set_visible(total > 0);
let label = match (rentals, reservations) {
(r, 0) => format!("Rentals ({})", r),
(0, b) => format!("Reserved ({})", b),
(r, b) => format!("Rentals ({}) + Reserved ({})", r, b),
};
rentals_btn.set_label(&label);
// Populate rentals list with styled rows
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);
let row = create_rental_row(bike);
rentals_list.append(&row);
}
list_stack.set_visible_child_name(if new_bikes.is_empty() { "empty" } else { "list" });
*bikes.borrow_mut() = new_bikes;
}
}
fn create_rental_row(bike: &Bike) -> ListBoxRow {
let row = ListBoxRow::new();
let hbox = Box::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(16)
.margin_end(16)
.build();
// Bike icon
let icon = Image::from_icon_name(if bike.is_reserved {
"alarm-symbolic"
} else {
"system-run-symbolic"
});
icon.add_css_class("dim-label");
// Info box
let info = Box::builder()
.orientation(Orientation::Vertical)
.hexpand(true)
.build();
let title_text = if bike.is_reserved {
format!("Bike {} (Reserved)", bike.id)
} else {
format!("Bike {}", bike.id)
};
let title = Label::builder()
.label(&title_text)
.css_classes(["heading"])
.xalign(0.0)
.build();
let subtitle = Label::builder()
.label(&format!(
"Code: {}{}",
bike.code,
if bike.electric_lock { "" } else { "" }
))
.css_classes(["dim-label", "caption"])
.xalign(0.0)
.build();
info.append(&title);
info.append(&subtitle);
hbox.append(&icon);
hbox.append(&info);
row.set_child(Some(&hbox));
row
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main() -> glib::ExitCode {
@ -208,39 +321,208 @@ fn build_ui(app: &Application) {
.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();
// WebView with custom map HTML
let webview = webkit6::WebView::new();
// Configure WebView settings for CORS and JavaScript
let settings = Settings::new();
settings.set_enable_javascript(true);
settings.set_allow_file_access_from_file_urls(true);
settings.set_allow_universal_access_from_file_urls(true);
webview.set_settings(&settings);
let map_html = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<style>
html, body, #map { margin: 0; padding: 0; width: 100%; height: 100%; }
.bike-marker {
background: #0066cc;
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
border: 2px solid white;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
.bike-marker.empty { background: #999; }
.marker-cluster {
background: rgba(0, 102, 204, 0.6);
border-radius: 50%;
}
.marker-cluster div {
background: #0066cc;
color: white;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = L.map('map').setView([49.0069, 8.4037], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
const markers = L.markerClusterGroup({
maxClusterRadius: 80,
disableClusteringAtZoom: 14,
spiderfyOnMaxZoom: false,
showCoverageOnHover: false,
zoomToBoundsOnClick: true
});
let currentFilter = 0; // 0=all, 1=city, 2=cargo, 3=ebike
function loadMarkers(filter) {
currentFilter = filter;
markers.clearLayers();
fetch('https://api.nextbike.net/maps/nextbike-live.json?city=21')
.then(r => r.json())
.then(data => {
const places = data.countries?.[0]?.cities?.[0]?.places || [];
places.forEach(place => {
let bikeList = place.bike_list || [];
const bikeNumbers = place.bike_numbers || [];
// Filter bikes based on type (121=E-bike, others=Standard)
if (filter > 0 && bikeList.length > 0) {
bikeList = bikeList.filter(b => {
const t = b.bike_type || 0;
const isEbike = t === 121 || (b.pedelec_battery !== null && b.pedelec_battery !== undefined);
if (filter === 1) return !isEbike; // standard
if (filter === 2) return isEbike; // ebike
return true;
});
}
const count = filter === 0 ? (place.bikes_available_to_rent || 0) : bikeList.length;
if (filter > 0 && count === 0) return; // Skip empty stations when filtering
const icon = L.divIcon({
className: '',
html: `<div class="bike-marker ${count === 0 ? 'empty' : ''}">${count}</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
});
const marker = L.marker([place.lat, place.lng], { icon });
marker.on('click', function(e) {
L.DomEvent.stopPropagation(e);
if (count > 0) {
let bikes = [];
if (bikeList.length > 0) {
bikes = bikeList.map(b => ({
number: String(b.number),
bikeType: b.bike_type || 0,
electricLock: b.electric_lock || false
}));
} else if (bikeNumbers.length > 0) {
bikes = bikeNumbers.map(num => ({
number: String(num),
bikeType: 0,
electricLock: false
}));
}
if (bikes.length > 0) {
window.location.href = 'app://station?' + encodeURIComponent(JSON.stringify({
stationName: place.name,
stationId: place.uid,
bikes: bikes
}));
}
}
});
markers.addLayer(marker);
});
map.removeLayer(markers);
map.addLayer(markers);
});
}
loadMarkers(0);
map.addLayer(markers);
</script>
</body>
</html>"#;
webview.load_html(map_html, Some("https://nextbike.net/"));
webview.set_vexpand(true);
webview.set_hexpand(true);
// Floating buttons at bottom of map
let button_box = Box::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Center)
.valign(gtk::Align::End)
.margin_bottom(16)
.build();
let rentals_btn = Button::builder()
.css_classes(["pill", "osd"])
.visible(false)
.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)
.css_classes(["suggested-action", "pill", "osd"])
.build();
button_box.append(&rent_btn);
button_box.append(&rentals_btn);
let overlay = Overlay::new();
overlay.set_child(Some(&webview));
overlay.add_overlay(&button_box);
let main_hdr = HeaderBar::new();
let logout_btn = Button::builder()
.icon_name("system-log-out-symbolic")
.tooltip_text("Logout")
// Menu with gio actions
let menu = gio::Menu::new();
// Bike type submenu with radio items
let type_submenu = gio::Menu::new();
type_submenu.append(Some("All bikes"), Some("app.bike-filter::all"));
type_submenu.append(Some("Standard bikes"), Some("app.bike-filter::standard"));
type_submenu.append(Some("E-bikes"), Some("app.bike-filter::ebike"));
menu.append_submenu(Some("Bike Type"), &type_submenu);
// Logout action
menu.append(Some("Logout"), Some("app.logout"));
let menu_btn = MenuButton::builder()
.icon_name("open-menu-symbolic")
.menu_model(&menu)
.tooltip_text("Menu")
.build();
// Stateful action for bike filter (radio behavior)
let bike_filter_action = gio::SimpleAction::new_stateful(
"bike-filter",
Some(glib::VariantTy::STRING),
&"all".to_variant(),
);
let refresh_btn = Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Refresh")
.build();
main_hdr.pack_end(&logout_btn);
main_hdr.pack_end(&menu_btn);
main_hdr.pack_start(&refresh_btn);
// ── Bottom sheet (rent + return) ──────────────────────────────────────────
@ -258,9 +540,23 @@ fn build_ui(app: &Application) {
let rent_submit = Button::builder()
.label("Rent")
.css_classes(["suggested-action", "pill"])
.hexpand(true)
.build();
let reserve_submit = Button::builder()
.label("Reserve")
.css_classes(["pill"])
.hexpand(true)
.build();
let rent_spinner = Spinner::new();
let button_row = Box::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.homogeneous(true)
.build();
button_row.append(&rent_submit);
button_row.append(&reserve_submit);
let rent_sheet = Box::builder()
.orientation(Orientation::Vertical)
.spacing(12)
@ -271,7 +567,7 @@ fn build_ui(app: &Application) {
.build();
rent_form.append(&bike_entry);
rent_form.append(&rent_err);
rent_form.append(&rent_submit);
rent_form.append(&button_row);
rent_form.append(&rent_spinner);
rent_sheet.append(&rent_form);
@ -316,10 +612,42 @@ fn build_ui(app: &Application) {
.build();
ret_sheet.append(&ret_inner);
// — Rentals sheet —
let rentals_list = ListBox::builder()
.css_classes(["boxed-list"])
.selection_mode(gtk::SelectionMode::None)
.build();
let rentals_scroll = ScrolledWindow::builder()
.vexpand(true)
.max_content_height(300)
.child(&rentals_list)
.build();
let rentals_sheet = Box::builder()
.orientation(Orientation::Vertical)
.spacing(12)
.build();
rentals_sheet.append(&rentals_scroll);
// — Station bikes sheet —
let station_list = ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["navigation-sidebar"])
.build();
let station_sheet = Box::builder()
.orientation(Orientation::Vertical)
.spacing(12)
.build();
station_sheet.append(&station_list);
// — Shared sheet stack —
let sheet_stack = Stack::new();
sheet_stack.add_named(&rent_sheet, Some("rent"));
sheet_stack.add_named(&ret_sheet, Some("return"));
sheet_stack.add_named(&rentals_sheet, Some("rentals"));
sheet_stack.add_named(&station_sheet, Some("station"));
let sheet_box = Box::builder()
.orientation(Orientation::Vertical)
@ -335,10 +663,7 @@ fn build_ui(app: &Application) {
.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));
bottom_sheet.set_content(Some(&overlay));
let main_body = Box::builder().orientation(Orientation::Vertical).build();
main_body.append(&main_hdr);
@ -375,8 +700,8 @@ fn build_ui(app: &Application) {
let nav = nav.clone();
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
login_btn.connect_clicked(move |_| {
let p = phone.text().to_string();
let n = pin.text().to_string();
@ -395,8 +720,8 @@ fn build_ui(app: &Application) {
let nav = nav.clone();
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || api_login(&p, &n)).await {
Ok(r) => r,
@ -409,7 +734,7 @@ fn build_ui(app: &Application) {
save_loginkey(&key);
*loginkey.borrow_mut() = Some(key.clone());
nav.pop();
load_rentals(key, bikes, bikes_list, list_stack).await;
load_rentals(key, bikes, rentals_list, rentals_btn).await;
}
Err(e) => {
err.set_label(&e);
@ -420,31 +745,73 @@ fn build_ui(app: &Application) {
});
}
// ── Logout button ─────────────────────────────────────────────────────────
// ── Menu actions ─────────────────────────────────────────────────────────
// Logout action
let logout_action = gio::SimpleAction::new("logout", None);
{
let nav = nav.clone();
let login_page = login_page.clone();
let loginkey = loginkey.clone();
logout_btn.connect_clicked(move |_| {
logout_action.connect_activate(move |_, _| {
clear_loginkey();
*loginkey.borrow_mut() = None;
nav.push(&login_page);
});
}
app.add_action(&logout_action);
// Bike filter action (stateful for radio behavior)
{
let webview = webview.clone();
bike_filter_action.connect_activate(move |a, param| {
if let Some(filter_type) = param.and_then(|p| p.str()) {
a.set_state(&filter_type.to_variant());
let filter_num = match filter_type {
"all" => 0,
"standard" => 1,
"ebike" => 2,
_ => 0,
};
let js = format!("loadMarkers({});", filter_num);
webview.evaluate_javascript(&js, None::<&str>, None::<&str>, None::<&gio::Cancellable>, |_| {});
}
});
}
app.add_action(&bike_filter_action);
// ── Refresh button ────────────────────────────────────────────────────────
{
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
let webview = webview.clone();
let bike_filter_action = bike_filter_action.clone();
refresh_btn.connect_clicked(move |_| {
// Reload the map data via JavaScript using current filter
let filter_state = bike_filter_action.state().and_then(|s| s.str().map(|s| s.to_string()));
let filter = match filter_state.as_deref() {
Some("standard") => 1,
Some("ebike") => 2,
_ => 0,
};
let js = format!("loadMarkers({});", filter);
webview.evaluate_javascript(
&js,
None::<&str>,
None::<&str>,
None::<&gio::Cancellable>,
|_| {},
);
// Reload rentals
if let Some(key) = loginkey.borrow().clone() {
let bikes = bikes.clone();
let bikes_list = bikes_list.clone();
let list_stack = list_stack.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
glib::MainContext::default().spawn_local(async move {
load_rentals(key, bikes, bikes_list, list_stack).await;
load_rentals(key, bikes, rentals_list, rentals_btn).await;
});
}
});
@ -464,6 +831,16 @@ fn build_ui(app: &Application) {
});
}
// ── Open rentals sheet ───────────────────────────────────────────────────
{
let bottom_sheet = bottom_sheet.clone();
let sheet_stack = sheet_stack.clone();
rentals_btn.connect_clicked(move |_| {
sheet_stack.set_visible_child_name("rentals");
bottom_sheet.set_open(true);
});
}
// ── Rent submit ───────────────────────────────────────────────────────────
{
let loginkey = loginkey.clone();
@ -473,8 +850,8 @@ fn build_ui(app: &Application) {
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();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
rent_submit.connect_clicked(move |_| {
let id = entry.text().to_string();
if id.is_empty() {
@ -492,8 +869,8 @@ fn build_ui(app: &Application) {
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 rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.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 {
@ -507,7 +884,57 @@ fn build_ui(app: &Application) {
err.set_visible(true);
} else {
bottom_sheet.set_open(false);
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
load_rentals(key_reload, bikes, rentals_list, rentals_btn).await;
}
});
}
});
}
// ── Reserve submit ───────────────────────────────────────────────────────
{
let loginkey = loginkey.clone();
let entry = bike_entry.clone();
let err = rent_err.clone();
let spinner = rent_spinner.clone();
let btn = reserve_submit.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
reserve_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 rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || api_book(&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, rentals_list, rentals_btn).await;
}
});
}
@ -516,14 +943,13 @@ fn build_ui(app: &Application) {
// ── 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| {
rentals_list.connect_row_activated(move |_, row| {
let idx = row.index() as usize;
let bike = bikes.borrow().get(idx).cloned();
if let Some(bike) = bike {
@ -532,7 +958,6 @@ fn build_ui(app: &Application) {
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);
}
});
}
@ -547,8 +972,8 @@ fn build_ui(app: &Application) {
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();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
ret_submit.connect_clicked(move |_| {
let station = entry.text().to_string();
if station.is_empty() {
@ -568,8 +993,8 @@ fn build_ui(app: &Application) {
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 rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || {
@ -587,20 +1012,159 @@ fn build_ui(app: &Application) {
err.set_visible(true);
} else {
bottom_sheet.set_open(false);
load_rentals(key_reload, bikes, bikes_list, list_stack).await;
load_rentals(key_reload, bikes, rentals_list, rentals_btn).await;
}
});
}
});
}
// ── Handle station click via navigation ───────────────────────────────────
{
let station_list = station_list.clone();
let sheet_stack = sheet_stack.clone();
let bottom_sheet = bottom_sheet.clone();
let loginkey = loginkey.clone();
let bikes = bikes.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
webview.connect_decide_policy(move |_, decision, decision_type| {
use webkit6::PolicyDecisionType;
if decision_type == PolicyDecisionType::NavigationAction {
if let Some(nav_decision) = decision.downcast_ref::<webkit6::NavigationPolicyDecision>() {
if let Some(nav_action) = nav_decision.navigation_action() {
if let Some(request) = nav_action.request() {
if let Some(uri) = request.uri() {
if uri.starts_with("app://station?") {
decision.ignore();
let json_encoded = uri.strip_prefix("app://station?").unwrap_or("");
if let Ok(json_str) = urlencoding::decode(json_encoded) {
if let Ok(data) = serde_json::from_str::<serde_json::Value>(&json_str) {
while let Some(child) = station_list.first_child() {
station_list.remove(&child);
}
if let Some(bike_arr) = data["bikes"].as_array() {
for bike in bike_arr {
let number = bike["number"].as_str().unwrap_or("").to_string();
let bike_type = bike["bikeType"].as_i64().unwrap_or(0);
let is_ebike = bike_type == 121;
let icon = if is_ebike { "" } else { "🚲" };
let row = ListBoxRow::builder()
.activatable(false)
.selectable(false)
.build();
let hbox = Box::builder()
.orientation(Orientation::Horizontal)
.spacing(8)
.margin_top(8)
.margin_bottom(8)
.build();
let label = Label::builder()
.label(&format!("{} {}", icon, number))
.hexpand(true)
.xalign(0.0)
.build();
let rent_btn = Button::builder()
.label("Rent")
.css_classes(["suggested-action", "pill"])
.build();
let reserve_btn = Button::builder()
.label("Reserve")
.css_classes(["pill"])
.build();
// Connect rent button
{
let bike_id = number.clone();
let loginkey = loginkey.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
rent_btn.connect_clicked(move |btn| {
if let Some(key) = loginkey.borrow().clone() {
btn.set_sensitive(false);
let bike_id = bike_id.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move {
let result = gio::spawn_blocking(move || api_rent(&key, &bike_id)).await;
if result.is_ok() {
bottom_sheet.set_open(false);
load_rentals(key_reload, bikes, rentals_list, rentals_btn).await;
}
});
}
});
}
// Connect reserve button
{
let bike_id = number.clone();
let loginkey = loginkey.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
reserve_btn.connect_clicked(move |btn| {
if let Some(key) = loginkey.borrow().clone() {
btn.set_sensitive(false);
let bike_id = bike_id.clone();
let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move {
let result = gio::spawn_blocking(move || api_book(&key, &bike_id)).await;
if result.is_ok() {
bottom_sheet.set_open(false);
load_rentals(key_reload, bikes, rentals_list, rentals_btn).await;
}
});
}
});
}
hbox.append(&label);
hbox.append(&reserve_btn);
hbox.append(&rent_btn);
row.set_child(Some(&hbox));
station_list.append(&row);
}
}
sheet_stack.set_visible_child_name("station");
bottom_sheet.set_open(true);
}
}
return true;
}
}
}
}
}
}
false
});
}
// ── 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();
let rentals_list = rentals_list.clone();
let rentals_btn = rentals_btn.clone();
glib::MainContext::default().spawn_local(async move {
load_rentals(key, bikes, bikes_list, list_stack).await;
load_rentals(key, bikes, rentals_list, rentals_btn).await;
});
}