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:
parent
9961a31936
commit
256da4b440
6 changed files with 847 additions and 171 deletions
740
src/main.rs
740
src/main.rs
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue