From 256da4b44036b3208a6130a12d7a3549954a13f8 Mon Sep 17 00:00:00 2001 From: Jonas Heinrich Date: Fri, 27 Mar 2026 22:41:24 +0100 Subject: [PATCH] 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 --- Cargo.lock | 257 +++++--- Cargo.toml | 6 +- data/org.nextbike.NextCompanion.desktop | 10 + flake.nix | 3 + org.nextbike.NextCompanion.yml | 2 + src/main.rs | 740 +++++++++++++++++++++--- 6 files changed, 847 insertions(+), 171 deletions(-) create mode 100644 data/org.nextbike.NextCompanion.desktop diff --git a/Cargo.lock b/Cargo.lock index a76249b..eaef971 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,9 +40,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cairo-rs" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ "bitflags", "cairo-sys-rs", @@ -52,9 +52,9 @@ dependencies = [ [[package]] name = "cairo-sys-rs" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -230,9 +230,9 @@ dependencies = [ [[package]] name = "gdk-pixbuf" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" dependencies = [ "gdk-pixbuf-sys", "gio", @@ -242,9 +242,9 @@ dependencies = [ [[package]] name = "gdk-pixbuf-sys" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" dependencies = [ "gio-sys", "glib-sys", @@ -255,9 +255,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" +checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -270,9 +270,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" +checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -314,9 +314,9 @@ dependencies = [ [[package]] name = "gio" -version = "0.21.5" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" +checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03" dependencies = [ "futures-channel", "futures-core", @@ -331,9 +331,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" dependencies = [ "glib-sys", "gobject-sys", @@ -344,9 +344,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.21.5" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c" dependencies = [ "bitflags", "futures-channel", @@ -365,12 +365,11 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.21.5" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" dependencies = [ "heck", - "proc-macro-crate", "proc-macro2", "quote", "syn", @@ -378,9 +377,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.21.5" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" dependencies = [ "libc", "system-deps", @@ -388,9 +387,9 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" dependencies = [ "glib-sys", "libc", @@ -399,9 +398,9 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" dependencies = [ "glib", "graphene-sys", @@ -410,9 +409,9 @@ dependencies = [ [[package]] name = "graphene-sys" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" dependencies = [ "glib-sys", "libc", @@ -422,9 +421,9 @@ dependencies = [ [[package]] name = "gsk4" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" dependencies = [ "cairo-rs", "gdk4", @@ -437,9 +436,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -453,9 +452,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" +checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe" dependencies = [ "cairo-rs", "field-offset", @@ -474,9 +473,9 @@ dependencies = [ [[package]] name = "gtk4-macros" -version = "0.10.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -486,9 +485,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" +checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -735,9 +734,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -745,9 +744,32 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore6" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" +dependencies = [ + "glib", + "javascriptcore6-sys", + "libc", +] + +[[package]] +name = "javascriptcore6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] [[package]] name = "js-sys" @@ -761,9 +783,9 @@ dependencies = [ [[package]] name = "libadwaita" -version = "0.8.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb09e12bf8f73342b3315c839d0a7668cc0ccebd78490c49fec48bab15d5484b" +checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" dependencies = [ "gdk4", "gio", @@ -776,9 +798,9 @@ dependencies = [ [[package]] name = "libadwaita-sys" -version = "0.8.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7f94227ba87eb596fecada2491f04e357d507324142f77bf76d9e6be4a3e31" +checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" dependencies = [ "gdk4-sys", "gio-sys", @@ -798,9 +820,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -840,9 +862,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -858,13 +880,15 @@ dependencies = [ "libadwaita", "reqwest", "serde_json", + "urlencoding", + "webkit6", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "option-ext" @@ -874,9 +898,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "pango" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" +checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee" dependencies = [ "gio", "glib", @@ -886,9 +910,9 @@ dependencies = [ [[package]] name = "pango-sys" -version = "0.21.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" dependencies = [ "glib-sys", "gobject-sys", @@ -1161,9 +1185,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -1233,9 +1257,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -1280,6 +1304,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soup3" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1394,9 +1444,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -1443,7 +1493,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1457,39 +1507,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" @@ -1585,6 +1635,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1700,6 +1756,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webkit6" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "javascriptcore6", + "libc", + "soup3", + "webkit6-sys", +] + +[[package]] +name = "webkit6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "javascriptcore6-sys", + "libc", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -1942,6 +2031,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -1983,18 +2078,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index dbc3fc0..b80cd35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2021" [dependencies] -gtk = { package = "gtk4", version = "0.10" } -adw = { package = "libadwaita", version = "0.8", features = ["v1_6"] } +gtk = { package = "gtk4", version = "0.11" } +adw = { package = "libadwaita", version = "0.9", features = ["v1_6"] } +webkit6 = "0.6" reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false } serde_json = "1" +urlencoding = "2" dirs = "5" diff --git a/data/org.nextbike.NextCompanion.desktop b/data/org.nextbike.NextCompanion.desktop new file mode 100644 index 0000000..a3e9465 --- /dev/null +++ b/data/org.nextbike.NextCompanion.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=NextCompanion +Comment=Nextbike client for Linux +Exec=next-companion +Icon=org.nextbike.NextCompanion +Terminal=false +Type=Application +Categories=Utility;GTK; +Keywords=bike;rental;nextbike; +StartupNotify=true diff --git a/flake.nix b/flake.nix index 0fe5510..0647464 100644 --- a/flake.nix +++ b/flake.nix @@ -16,6 +16,7 @@ gtk4 libadwaita glib + webkitgtk_6_0 ]; buildDeps = with pkgs; [ pkg-config @@ -40,6 +41,8 @@ postInstall = '' install -Dm644 data/icons/org.nextbike.NextCompanion.png \ $out/share/icons/hicolor/512x512/apps/org.nextbike.NextCompanion.png + install -Dm644 data/org.nextbike.NextCompanion.desktop \ + $out/share/applications/org.nextbike.NextCompanion.desktop ''; }; diff --git a/org.nextbike.NextCompanion.yml b/org.nextbike.NextCompanion.yml index 38049f9..a2baca3 100644 --- a/org.nextbike.NextCompanion.yml +++ b/org.nextbike.NextCompanion.yml @@ -37,6 +37,8 @@ modules: /app/bin/next-companion - install -Dm644 data/icons/org.nextbike.NextCompanion.png /app/share/icons/hicolor/512x512/apps/org.nextbike.NextCompanion.png + - install -Dm644 data/org.nextbike.NextCompanion.desktop + /app/share/applications/org.nextbike.NextCompanion.desktop sources: - type: dir path: . diff --git a/src/main.rs b/src/main.rs index b730196..999e9d3 100644 --- a/src/main.rs +++ b/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 { } fn api_get_rentals(loginkey: &str) -> Result, 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, 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::(&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>>, bikes_list: ListBox, list_stack: Stack) { +async fn load_rentals( + key: String, + bikes: Rc>>, + 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#" + + + + + + + + + + + + +
+ + +"#; + 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::() { + 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::(&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; }); }