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

257
Cargo.lock generated
View file

@ -40,9 +40,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"cairo-sys-rs", "cairo-sys-rs",
@ -52,9 +52,9 @@ dependencies = [
[[package]] [[package]]
name = "cairo-sys-rs" name = "cairo-sys-rs"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
@ -63,9 +63,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.56" version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -230,9 +230,9 @@ dependencies = [
[[package]] [[package]]
name = "gdk-pixbuf" name = "gdk-pixbuf"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
dependencies = [ dependencies = [
"gdk-pixbuf-sys", "gdk-pixbuf-sys",
"gio", "gio",
@ -242,9 +242,9 @@ dependencies = [
[[package]] [[package]]
name = "gdk-pixbuf-sys" name = "gdk-pixbuf-sys"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
dependencies = [ dependencies = [
"gio-sys", "gio-sys",
"glib-sys", "glib-sys",
@ -255,9 +255,9 @@ dependencies = [
[[package]] [[package]]
name = "gdk4" name = "gdk4"
version = "0.10.3" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7"
dependencies = [ dependencies = [
"cairo-rs", "cairo-rs",
"gdk-pixbuf", "gdk-pixbuf",
@ -270,9 +270,9 @@ dependencies = [
[[package]] [[package]]
name = "gdk4-sys" name = "gdk4-sys"
version = "0.10.3" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377"
dependencies = [ dependencies = [
"cairo-sys-rs", "cairo-sys-rs",
"gdk-pixbuf-sys", "gdk-pixbuf-sys",
@ -314,9 +314,9 @@ dependencies = [
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.21.5" version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -331,9 +331,9 @@ dependencies = [
[[package]] [[package]]
name = "gio-sys" name = "gio-sys"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
@ -344,9 +344,9 @@ dependencies = [
[[package]] [[package]]
name = "glib" name = "glib"
version = "0.21.5" version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"futures-channel", "futures-channel",
@ -365,12 +365,11 @@ dependencies = [
[[package]] [[package]]
name = "glib-macros" name = "glib-macros"
version = "0.21.5" version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
@ -378,9 +377,9 @@ dependencies = [
[[package]] [[package]]
name = "glib-sys" name = "glib-sys"
version = "0.21.5" version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642"
dependencies = [ dependencies = [
"libc", "libc",
"system-deps", "system-deps",
@ -388,9 +387,9 @@ dependencies = [
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
@ -399,9 +398,9 @@ dependencies = [
[[package]] [[package]]
name = "graphene-rs" name = "graphene-rs"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1"
dependencies = [ dependencies = [
"glib", "glib",
"graphene-sys", "graphene-sys",
@ -410,9 +409,9 @@ dependencies = [
[[package]] [[package]]
name = "graphene-sys" name = "graphene-sys"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
@ -422,9 +421,9 @@ dependencies = [
[[package]] [[package]]
name = "gsk4" name = "gsk4"
version = "0.10.3" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62"
dependencies = [ dependencies = [
"cairo-rs", "cairo-rs",
"gdk4", "gdk4",
@ -437,9 +436,9 @@ dependencies = [
[[package]] [[package]]
name = "gsk4-sys" name = "gsk4-sys"
version = "0.10.3" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a"
dependencies = [ dependencies = [
"cairo-sys-rs", "cairo-sys-rs",
"gdk4-sys", "gdk4-sys",
@ -453,9 +452,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4" name = "gtk4"
version = "0.10.3" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe"
dependencies = [ dependencies = [
"cairo-rs", "cairo-rs",
"field-offset", "field-offset",
@ -474,9 +473,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-macros" name = "gtk4-macros"
version = "0.10.3" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
@ -486,9 +485,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-sys" name = "gtk4-sys"
version = "0.10.3" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e"
dependencies = [ dependencies = [
"cairo-sys-rs", "cairo-sys-rs",
"gdk-pixbuf-sys", "gdk-pixbuf-sys",
@ -735,9 +734,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]] [[package]]
name = "iri-string" name = "iri-string"
version = "0.7.10" version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@ -745,9 +744,32 @@ dependencies = [
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "js-sys" name = "js-sys"
@ -761,9 +783,9 @@ dependencies = [
[[package]] [[package]]
name = "libadwaita" name = "libadwaita"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb09e12bf8f73342b3315c839d0a7668cc0ccebd78490c49fec48bab15d5484b" checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4"
dependencies = [ dependencies = [
"gdk4", "gdk4",
"gio", "gio",
@ -776,9 +798,9 @@ dependencies = [
[[package]] [[package]]
name = "libadwaita-sys" name = "libadwaita-sys"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d7f94227ba87eb596fecada2491f04e357d507324142f77bf76d9e6be4a3e31" checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d"
dependencies = [ dependencies = [
"gdk4-sys", "gdk4-sys",
"gio-sys", "gio-sys",
@ -798,9 +820,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.14" version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@ -840,9 +862,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@ -858,13 +880,15 @@ dependencies = [
"libadwaita", "libadwaita",
"reqwest", "reqwest",
"serde_json", "serde_json",
"urlencoding",
"webkit6",
] ]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
@ -874,9 +898,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee"
dependencies = [ dependencies = [
"gio", "gio",
"glib", "glib",
@ -886,9 +910,9 @@ dependencies = [
[[package]] [[package]]
name = "pango-sys" name = "pango-sys"
version = "0.21.5" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
@ -1161,9 +1185,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@ -1233,9 +1257,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.0.4" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@ -1280,6 +1304,32 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -1394,9 +1444,9 @@ dependencies = [
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [ dependencies = [
"tinyvec_macros", "tinyvec_macros",
] ]
@ -1443,7 +1493,7 @@ dependencies = [
"toml_datetime 0.7.5+spec-1.1.0", "toml_datetime 0.7.5+spec-1.1.0",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
"winnow", "winnow 0.7.15",
] ]
[[package]] [[package]]
@ -1457,39 +1507,39 @@ dependencies = [
[[package]] [[package]]
name = "toml_datetime" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
[[package]] [[package]]
name = "toml_edit" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime 1.0.0+spec-1.1.0", "toml_datetime 1.1.0+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow 1.0.0",
] ]
[[package]] [[package]]
name = "toml_parser" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [ dependencies = [
"winnow", "winnow 1.0.0",
] ]
[[package]] [[package]]
name = "toml_writer" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
[[package]] [[package]]
name = "tower" name = "tower"
@ -1585,6 +1635,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@ -1700,6 +1756,39 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.6"
@ -1942,6 +2031,12 @@ name = "winnow"
version = "0.7.15" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -1983,18 +2078,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.42" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.42" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -4,8 +4,10 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
gtk = { package = "gtk4", version = "0.10" } gtk = { package = "gtk4", version = "0.11" }
adw = { package = "libadwaita", version = "0.8", features = ["v1_6"] } adw = { package = "libadwaita", version = "0.9", features = ["v1_6"] }
webkit6 = "0.6"
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false } reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
serde_json = "1" serde_json = "1"
urlencoding = "2"
dirs = "5" dirs = "5"

View file

@ -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

View file

@ -16,6 +16,7 @@
gtk4 gtk4
libadwaita libadwaita
glib glib
webkitgtk_6_0
]; ];
buildDeps = with pkgs; [ buildDeps = with pkgs; [
pkg-config pkg-config
@ -40,6 +41,8 @@
postInstall = '' postInstall = ''
install -Dm644 data/icons/org.nextbike.NextCompanion.png \ install -Dm644 data/icons/org.nextbike.NextCompanion.png \
$out/share/icons/hicolor/512x512/apps/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
''; '';
}; };

View file

@ -37,6 +37,8 @@ modules:
/app/bin/next-companion /app/bin/next-companion
- install -Dm644 data/icons/org.nextbike.NextCompanion.png - install -Dm644 data/icons/org.nextbike.NextCompanion.png
/app/share/icons/hicolor/512x512/apps/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: sources:
- type: dir - type: dir
path: . path: .

View file

@ -1,10 +1,13 @@
use adw::prelude::*; use adw::prelude::*;
use adw::{Application, ApplicationWindow, BottomSheet, Clamp, HeaderBar, NavigationPage, NavigationView}; use adw::{Application, ApplicationWindow, BottomSheet, Clamp, HeaderBar, NavigationPage, NavigationView};
use gtk::{ 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::gio;
use gtk::glib; use gtk::glib;
use webkit6::prelude::*;
use webkit6::Settings;
use std::cell::RefCell; use std::cell::RefCell;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@ -20,6 +23,7 @@ struct Bike {
id: String, id: String,
code: String, code: String,
electric_lock: bool, electric_lock: bool,
is_reserved: bool,
} }
// ── Persistent login key ────────────────────────────────────────────────────── // ── 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> { 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")) .post(format!("{BASE_URL}/api/getOpenRentals.json"))
.form(&[("apikey", API_KEY), ("loginkey", loginkey)]) .form(&[("apikey", API_KEY), ("loginkey", loginkey)])
.send() .send()
@ -76,17 +84,40 @@ fn api_get_rentals(loginkey: &str) -> Result<Vec<Bike>, String> {
let json: serde_json::Value = let json: serde_json::Value =
serde_json::from_str(&resp.text().map_err(|e| e.to_string())?) serde_json::from_str(&resp.text().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let arr = json["rentalCollection"] if let Some(arr) = json["rentalCollection"].as_array() {
.as_array() for b in arr {
.ok_or_else(|| "No rental data".to_string())?; bikes.push(Bike {
Ok(arr id: b["bike"].as_str().unwrap_or("").to_string(),
.iter() code: b["code"].as_str().unwrap_or("").to_string(),
.map(|b| Bike { electric_lock: b["electric_lock"].as_str().map_or(false, |s| s == "true"),
id: b["bike"].as_str().unwrap_or("").to_string(), is_reserved: false,
code: b["code"].as_str().unwrap_or("").to_string(), });
electric_lock: b["electric_lock"].as_str().map_or(false, |s| s == "true"), }
}) }
.collect())
// 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> { 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(()) 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> { fn api_return(loginkey: &str, bike_id: &str, station_id: &str) -> Result<(), String> {
reqwest::blocking::Client::new() reqwest::blocking::Client::new()
.post(format!("{BASE_URL}/api/return.json")) .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 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 { let result = match gio::spawn_blocking(move || api_get_rentals(&key)).await {
Ok(r) => r, Ok(r) => r,
Err(_) => return, Err(_) => return,
}; };
if let Ok(new_bikes) = result { if let Ok(new_bikes) = result {
while let Some(child) = bikes_list.first_child() { // Clear the rentals list
bikes_list.remove(&child); 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 { for bike in &new_bikes {
let text = format!( let row = create_rental_row(bike);
"Bike {} · code: {}{}", rentals_list.append(&row);
bike.id,
bike.code,
if bike.electric_lock { "" } else { "" }
);
let lbl = Label::builder()
.label(&text)
.xalign(0.0)
.margin_top(14)
.margin_bottom(14)
.margin_start(12)
.margin_end(12)
.build();
let row = ListBoxRow::new();
row.set_child(Some(&lbl));
bikes_list.append(&row);
} }
list_stack.set_visible_child_name(if new_bikes.is_empty() { "empty" } else { "list" });
*bikes.borrow_mut() = new_bikes; *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 ─────────────────────────────────────────────────────────────── // ── Entry point ───────────────────────────────────────────────────────────────
fn main() -> glib::ExitCode { fn main() -> glib::ExitCode {
@ -208,39 +321,208 @@ fn build_ui(app: &Application) {
.build(); .build();
// ── Main page ───────────────────────────────────────────────────────────── // ── 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() let rent_btn = Button::builder()
.label("Rent a Bike") .label("Rent a Bike")
.css_classes(["suggested-action", "pill"]) .css_classes(["suggested-action", "pill", "osd"])
.margin_top(8).margin_bottom(12).margin_start(12).margin_end(12)
.build(); .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 main_hdr = HeaderBar::new();
let logout_btn = Button::builder()
.icon_name("system-log-out-symbolic") // Menu with gio actions
.tooltip_text("Logout") 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(); .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() let refresh_btn = Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text("Refresh") .tooltip_text("Refresh")
.build(); .build();
main_hdr.pack_end(&logout_btn); main_hdr.pack_end(&menu_btn);
main_hdr.pack_start(&refresh_btn); main_hdr.pack_start(&refresh_btn);
// ── Bottom sheet (rent + return) ────────────────────────────────────────── // ── Bottom sheet (rent + return) ──────────────────────────────────────────
@ -258,9 +540,23 @@ fn build_ui(app: &Application) {
let rent_submit = Button::builder() let rent_submit = Button::builder()
.label("Rent") .label("Rent")
.css_classes(["suggested-action", "pill"]) .css_classes(["suggested-action", "pill"])
.hexpand(true)
.build();
let reserve_submit = Button::builder()
.label("Reserve")
.css_classes(["pill"])
.hexpand(true)
.build(); .build();
let rent_spinner = Spinner::new(); 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() let rent_sheet = Box::builder()
.orientation(Orientation::Vertical) .orientation(Orientation::Vertical)
.spacing(12) .spacing(12)
@ -271,7 +567,7 @@ fn build_ui(app: &Application) {
.build(); .build();
rent_form.append(&bike_entry); rent_form.append(&bike_entry);
rent_form.append(&rent_err); rent_form.append(&rent_err);
rent_form.append(&rent_submit); rent_form.append(&button_row);
rent_form.append(&rent_spinner); rent_form.append(&rent_spinner);
rent_sheet.append(&rent_form); rent_sheet.append(&rent_form);
@ -316,10 +612,42 @@ fn build_ui(app: &Application) {
.build(); .build();
ret_sheet.append(&ret_inner); 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 — // — Shared sheet stack —
let sheet_stack = Stack::new(); let sheet_stack = Stack::new();
sheet_stack.add_named(&rent_sheet, Some("rent")); sheet_stack.add_named(&rent_sheet, Some("rent"));
sheet_stack.add_named(&ret_sheet, Some("return")); 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() let sheet_box = Box::builder()
.orientation(Orientation::Vertical) .orientation(Orientation::Vertical)
@ -335,10 +663,7 @@ fn build_ui(app: &Application) {
.sheet(&sheet_box) .sheet(&sheet_box)
.build(); .build();
let main_content = Box::builder().orientation(Orientation::Vertical).build(); bottom_sheet.set_content(Some(&overlay));
main_content.append(&scroll);
main_content.append(&rent_btn);
bottom_sheet.set_content(Some(&main_content));
let main_body = Box::builder().orientation(Orientation::Vertical).build(); let main_body = Box::builder().orientation(Orientation::Vertical).build();
main_body.append(&main_hdr); main_body.append(&main_hdr);
@ -375,8 +700,8 @@ fn build_ui(app: &Application) {
let nav = nav.clone(); let nav = nav.clone();
let loginkey = loginkey.clone(); let loginkey = loginkey.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
login_btn.connect_clicked(move |_| { login_btn.connect_clicked(move |_| {
let p = phone.text().to_string(); let p = phone.text().to_string();
let n = pin.text().to_string(); let n = pin.text().to_string();
@ -395,8 +720,8 @@ fn build_ui(app: &Application) {
let nav = nav.clone(); let nav = nav.clone();
let loginkey = loginkey.clone(); let loginkey = loginkey.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
glib::MainContext::default().spawn_local(async move { glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || api_login(&p, &n)).await { let result = match gio::spawn_blocking(move || api_login(&p, &n)).await {
Ok(r) => r, Ok(r) => r,
@ -409,7 +734,7 @@ fn build_ui(app: &Application) {
save_loginkey(&key); save_loginkey(&key);
*loginkey.borrow_mut() = Some(key.clone()); *loginkey.borrow_mut() = Some(key.clone());
nav.pop(); nav.pop();
load_rentals(key, bikes, bikes_list, list_stack).await; load_rentals(key, bikes, rentals_list, rentals_btn).await;
} }
Err(e) => { Err(e) => {
err.set_label(&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 nav = nav.clone();
let login_page = login_page.clone(); let login_page = login_page.clone();
let loginkey = loginkey.clone(); let loginkey = loginkey.clone();
logout_btn.connect_clicked(move |_| { logout_action.connect_activate(move |_, _| {
clear_loginkey(); clear_loginkey();
*loginkey.borrow_mut() = None; *loginkey.borrow_mut() = None;
nav.push(&login_page); 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 ──────────────────────────────────────────────────────── // ── Refresh button ────────────────────────────────────────────────────────
{ {
let loginkey = loginkey.clone(); let loginkey = loginkey.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
let webview = webview.clone();
let bike_filter_action = bike_filter_action.clone();
refresh_btn.connect_clicked(move |_| { 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() { if let Some(key) = loginkey.borrow().clone() {
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
glib::MainContext::default().spawn_local(async move { 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 ─────────────────────────────────────────────────────────── // ── Rent submit ───────────────────────────────────────────────────────────
{ {
let loginkey = loginkey.clone(); let loginkey = loginkey.clone();
@ -473,8 +850,8 @@ fn build_ui(app: &Application) {
let btn = rent_submit.clone(); let btn = rent_submit.clone();
let bottom_sheet = bottom_sheet.clone(); let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
rent_submit.connect_clicked(move |_| { rent_submit.connect_clicked(move |_| {
let id = entry.text().to_string(); let id = entry.text().to_string();
if id.is_empty() { if id.is_empty() {
@ -492,8 +869,8 @@ fn build_ui(app: &Application) {
let err = err.clone(); let err = err.clone();
let bottom_sheet = bottom_sheet.clone(); let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
let key_reload = key.clone(); let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move { glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || api_rent(&key, &id)).await { 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); err.set_visible(true);
} else { } else {
bottom_sheet.set_open(false); 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 ─────────────────────────── // ── Click rental row → open return bottom sheet ───────────────────────────
{ {
let bottom_sheet = bottom_sheet.clone();
let sheet_stack = sheet_stack.clone(); let sheet_stack = sheet_stack.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let return_bike = return_bike.clone(); let return_bike = return_bike.clone();
let ret_inner = ret_inner.clone(); let ret_inner = ret_inner.clone();
let station_entry = station_entry.clone(); let station_entry = station_entry.clone();
let ret_err = ret_err.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 idx = row.index() as usize;
let bike = bikes.borrow().get(idx).cloned(); let bike = bikes.borrow().get(idx).cloned();
if let Some(bike) = bike { 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" }); ret_inner.set_visible_child_name(if bike.electric_lock { "electric" } else { "manual" });
*return_bike.borrow_mut() = Some(bike); *return_bike.borrow_mut() = Some(bike);
sheet_stack.set_visible_child_name("return"); 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 btn = ret_submit.clone();
let bottom_sheet = bottom_sheet.clone(); let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
ret_submit.connect_clicked(move |_| { ret_submit.connect_clicked(move |_| {
let station = entry.text().to_string(); let station = entry.text().to_string();
if station.is_empty() { if station.is_empty() {
@ -568,8 +993,8 @@ fn build_ui(app: &Application) {
let err = err.clone(); let err = err.clone();
let bottom_sheet = bottom_sheet.clone(); let bottom_sheet = bottom_sheet.clone();
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
let key_reload = key.clone(); let key_reload = key.clone();
glib::MainContext::default().spawn_local(async move { glib::MainContext::default().spawn_local(async move {
let result = match gio::spawn_blocking(move || { let result = match gio::spawn_blocking(move || {
@ -587,20 +1012,159 @@ fn build_ui(app: &Application) {
err.set_visible(true); err.set_visible(true);
} else { } else {
bottom_sheet.set_open(false); 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 ────────────────────────────────────────────────── // ── Initial rentals load ──────────────────────────────────────────────────
if let Some(key) = loginkey.borrow().clone() { if let Some(key) = loginkey.borrow().clone() {
let bikes = bikes.clone(); let bikes = bikes.clone();
let bikes_list = bikes_list.clone(); let rentals_list = rentals_list.clone();
let list_stack = list_stack.clone(); let rentals_btn = rentals_btn.clone();
glib::MainContext::default().spawn_local(async move { 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;
}); });
} }