From cf166c98045618a642d436c2b4e7bf8081932e01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:22:54 +0000 Subject: [PATCH 1/2] feat: hold-to-delete (3s) for sessions & exercise logs; proximity wake lock on Android Agent-Logs-Url: https://github.com/gfauredev/LogOut/sessions/ec2dd9cd-a427-4c82-b793-79c65466f9bb Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com> --- assets/_component.scss | 24 +++++++ assets/en.ftl | 1 + assets/es.ftl | 1 + assets/fr.ftl | 1 + src/components/completed_exercise_log.rs | 9 ++- src/components/hold_delete.rs | 84 ++++++++++++++++++++++++ src/components/home.rs | 42 ++---------- src/components/mod.rs | 2 + src/services/wake_lock.rs | 54 +++++++++++++++ 9 files changed, 178 insertions(+), 40 deletions(-) create mode 100644 src/components/hold_delete.rs diff --git a/assets/_component.scss b/assets/_component.scss index 455af8d7..d52976f3 100644 --- a/assets/_component.scss +++ b/assets/_component.scss @@ -420,4 +420,28 @@ ul.results { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +// ── Hold-to-delete button ────────────────────────────────────────────────── +.hold-del { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + + svg.hold-del-ring { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); + pointer-events: none; + + circle { + fill: none; + stroke: var(--less); + stroke-width: 3; + transition: stroke-dashoffset 0.1s linear; + } + } } \ No newline at end of file diff --git a/assets/en.ftl b/assets/en.ftl index 4bad8063..0eea73f9 100644 --- a/assets/en.ftl +++ b/assets/en.ftl @@ -12,6 +12,7 @@ session-repeat-title = Start a new session based on this one session-repeat-weekday-title = Repeat same-weekday session session-delete-title = Delete session session-show-more = +{ $count } more +hold-to-delete-hint = Hold for 3s to delete session-delete-confirm = Delete this session? session-delete-confirm-btn = 🗑️ Delete cancel-btn = ❌ Cancel diff --git a/assets/es.ftl b/assets/es.ftl index 04642cad..7055878d 100644 --- a/assets/es.ftl +++ b/assets/es.ftl @@ -12,6 +12,7 @@ session-repeat-title = Iniciar nueva sesión basada en esta session-repeat-weekday-title = Repetir la sesión del mismo día de la semana session-delete-title = Eliminar sesión session-show-more = +{ $count } más +hold-to-delete-hint = Mantener 3s para eliminar session-delete-confirm = ¿Eliminar esta sesión? session-delete-confirm-btn = 🗑️ Eliminar cancel-btn = ❌ Cancelar diff --git a/assets/fr.ftl b/assets/fr.ftl index c47f90b8..11da1c83 100644 --- a/assets/fr.ftl +++ b/assets/fr.ftl @@ -12,6 +12,7 @@ session-repeat-title = Démarrer une nouvelle séance basée sur celle-ci session-repeat-weekday-title = Répéter la séance du même jour de la semaine session-delete-title = Supprimer la séance session-show-more = +{ $count } autres +hold-to-delete-hint = Maintenir 3s pour supprimer session-delete-confirm = Supprimer cette séance ? session-delete-confirm-btn = 🗑️ Supprimer cancel-btn = ❌ Annuler diff --git a/src/components/completed_exercise_log.rs b/src/components/completed_exercise_log.rs index 6b65c2fe..18f0b9ad 100644 --- a/src/components/completed_exercise_log.rs +++ b/src/components/completed_exercise_log.rs @@ -1,4 +1,5 @@ use super::session_exercise_form::ExerciseInputForm; +use crate::components::HoldDeleteButton; use crate::models::{ format_time, parse_distance_km, parse_duration_seconds, parse_weight_kg, Category, ExerciseLog, Force, Weight, WorkoutSession, HG_PER_KG, M_PER_KM, @@ -79,15 +80,13 @@ pub fn CompletedExerciseLog( title: t!("log-edit-title"), "✏️" } - button { - class: "del", - title: t!("log-delete-title"), - onclick: move |_| { + HoldDeleteButton { + title: t!("log-delete-title").to_string(), + on_delete: move |()| { let mut current_session = session.read().clone(); current_session.exercise_logs.remove(idx); storage::save_session(current_session); }, - "🗑️" } } } diff --git a/src/components/hold_delete.rs b/src/components/hold_delete.rs new file mode 100644 index 00000000..24d25729 --- /dev/null +++ b/src/components/hold_delete.rs @@ -0,0 +1,84 @@ +use crate::utils::sleep_ms; +use crate::ToastSignal; +use dioxus::prelude::*; +use dioxus_i18n::t; + +/// Number of 100 ms ticks that must elapse while the button is held before the +/// delete action fires (30 × 100 ms = 3 s). +const HOLD_STEPS: u32 = 30; +/// Duration of each tick in milliseconds. +const HOLD_TICK_MS: u32 = 100; +/// SVG viewBox half-side (the SVG is `RING_SIZE × RING_SIZE`). +const RING_SIZE: f32 = 44.0; +/// Circle radius used for the progress ring (leaves room for the stroke). +const RING_RADIUS: f32 = 19.0; +/// Stroke-dasharray / full circumference of the progress ring. +const RING_CIRC: f32 = 2.0 * std::f32::consts::PI * RING_RADIUS; // ≈ 119.4 + +/// A delete button that requires the user to hold it for 3 seconds before +/// firing `on_delete`. While the button is held a circular SVG progress ring +/// fills around it. If the button is released early a toast hint is shown. +#[component] +pub fn HoldDeleteButton(on_delete: EventHandler<()>, title: String) -> Element { + let mut progress = use_signal(|| 0.0f32); + // Generation counter: incremented on each press and on each early release. + // The spawned task captures its generation and exits as soon as it drifts. + let mut gen = use_signal(|| 0u32); + + let hint_msg = t!("hold-to-delete-hint").to_string(); + + let offset = RING_CIRC * (1.0 - *progress.read()); + + rsx! { + div { class: "hold-del", + svg { + class: "hold-del-ring", + view_box: "0 0 {RING_SIZE} {RING_SIZE}", + "aria-hidden": "true", + circle { + cx: "{RING_SIZE / 2.0}", + cy: "{RING_SIZE / 2.0}", + r: "{RING_RADIUS}", + "stroke-dasharray": "{RING_CIRC}", + "stroke-dashoffset": "{offset}", + } + } + button { + class: "del", + title, + onpointerdown: move |_| { + let next = gen.peek().wrapping_add(1); + gen.set(next); + let hint = hint_msg.clone(); + let mut toast = consume_context::().0; + spawn(async move { + for step in 1..=HOLD_STEPS { + sleep_ms(HOLD_TICK_MS).await; + if *gen.peek() != next { + // Released early – show the hint toast. + toast.write().push_back(hint); + progress.set(0.0); + return; + } + progress.set(step as f32 / HOLD_STEPS as f32); + } + // Full 3 s elapsed – fire the delete action. + if *gen.peek() == next { + on_delete.call(()); + } + progress.set(0.0); + }); + }, + onpointerup: move |_| { + let next = gen.peek().wrapping_add(1); + gen.set(next); + }, + onpointerleave: move |_| { + let next = gen.peek().wrapping_add(1); + gen.set(next); + }, + "🗑️" + } + } + } +} diff --git a/src/components/home.rs b/src/components/home.rs index dadf65d8..b4e8ac9f 100644 --- a/src/components/home.rs +++ b/src/components/home.rs @@ -1,4 +1,4 @@ -use crate::components::{ActiveTab, BottomNav, SessionView}; +use crate::components::{ActiveTab, BottomNav, HoldDeleteButton, SessionView}; use crate::models::{format_time, WorkoutSession}; use crate::services::{exercise_db, storage}; use crate::{ExerciseSearchSignal, Route}; @@ -185,7 +185,6 @@ pub fn Home() -> Element { #[component] fn SessionCard(session: WorkoutSession, on_delete: EventHandler) -> Element { const MAX_VISIBLE: usize = 9; - let mut show_delete_confirm = use_signal(|| false); let mut show_all_exercises = use_signal(|| false); let mut show_notes = use_signal(|| false); let session_id = session.id.clone(); @@ -272,11 +271,12 @@ fn SessionCard(session: WorkoutSession, on_delete: EventHandler) -> Elem "🔁" } } - button { - class: "del", - onclick: move |_| show_delete_confirm.set(true), - title: t!("session-delete-title"), - "🗑️" + HoldDeleteButton { + title: t!("session-delete-title").to_string(), + on_delete: move |()| { + storage::delete_session(&session_id); + on_delete.call(session_id.clone()); + }, } } if !unique_exercises.is_empty() { @@ -314,34 +314,6 @@ fn SessionCard(session: WorkoutSession, on_delete: EventHandler) -> Elem } } } - if *show_delete_confirm.read() { - div { - class: "backdrop", - onclick: move |_| show_delete_confirm.set(false), - } - dialog { open: true, onclick: move |evt| evt.stop_propagation(), - p { {t!("session-delete-confirm")} } - div { - button { - onclick: { - let id = session_id.clone(); - move |_| { - storage::delete_session(&id); - on_delete.call(id.clone()); - show_delete_confirm.set(false); - } - }, - class: "del label", - {t!("session-delete-confirm-btn")} - } - button { - onclick: move |_| show_delete_confirm.set(false), - class: "back label", - {t!("cancel-btn")} - } - } - } - } } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index d7470753..574fa35d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -7,6 +7,7 @@ pub mod edit_exercise; pub mod exercise_card; pub mod exercise_form_fields; pub mod exercises; +pub mod hold_delete; pub mod home; pub mod more; mod session_exercise_form; @@ -19,5 +20,6 @@ pub use completed_exercise_log::CompletedExerciseLog; pub use edit_exercise::EditExercise; pub use exercise_card::ExerciseCard; pub use exercises::Exercises; +pub use hold_delete::HoldDeleteButton; pub use home::Home; pub use more::More; diff --git a/src/services/wake_lock.rs b/src/services/wake_lock.rs index 0a944441..af253690 100644 --- a/src/services/wake_lock.rs +++ b/src/services/wake_lock.rs @@ -170,6 +170,17 @@ fn acquire_android_wake_lock() { static SCREEN_WAKE_LOCK: std::sync::Mutex> = std::sync::Mutex::new(None); +/// JNI global reference to the `PROXIMITY_SCREEN_OFF_WAKE_LOCK` held while a +/// session is active over the lock screen. +/// +/// When this lock is held, Android's power manager monitors the proximity +/// sensor and automatically turns off the screen (and disables touch input) +/// when the phone is placed in a pocket or bag, preventing accidental touches. +/// The screen turns back on as soon as proximity is no longer detected. +#[cfg(target_os = "android")] +static PROXIMITY_WAKE_LOCK: std::sync::Mutex> = + std::sync::Mutex::new(None); + /// Configure Android lock-screen behaviour based on whether a session is active. /// /// When `active` is `true`: @@ -279,6 +290,42 @@ pub fn set_active_session_lock_screen(active: bool) { .new_global_ref(&wake_lock) .map_err(|e| format!("new_global_ref: {e}"))?; *guard = Some(global); + + // Acquire PROXIMITY_SCREEN_OFF_WAKE_LOCK (0x20) so that + // Android automatically turns off the screen (and disables + // touch input) whenever the proximity sensor fires — e.g. + // when the phone is slipped into a pocket — preventing + // accidental touches while the session is running. + let mut prox_guard = PROXIMITY_WAKE_LOCK.lock().unwrap(); + if prox_guard.is_none() { + // PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK = 32 (0x20) + let prox_tag = env + .new_string("logout:proximity") + .map_err(|e| format!("new_string proximity: {e}"))?; + let prox_lock = env + .call_method( + &pm, + "newWakeLock", + "(ILjava/lang/String;)Landroid/os/PowerManager$WakeLock;", + &[JValue::Int(0x20i32), (&prox_tag).into()], + ) + .map_err(|e| format!("newWakeLock proximity: {e}"))? + .l() + .map_err(|e| format!("ProximityWakeLock obj: {e}"))?; + env.call_method( + &prox_lock, + "setReferenceCounted", + "(Z)V", + &[JValue::Bool(0u8)], + ) + .map_err(|e| format!("setReferenceCounted proximity: {e}"))?; + env.call_method(&prox_lock, "acquire", "()V", &[]) + .map_err(|e| format!("acquire proximity: {e}"))?; + let prox_global = env + .new_global_ref(&prox_lock) + .map_err(|e| format!("new_global_ref proximity: {e}"))?; + *prox_guard = Some(prox_global); + } } } else { let mut guard = SCREEN_WAKE_LOCK.lock().unwrap(); @@ -287,6 +334,13 @@ pub fn set_active_session_lock_screen(active: bool) { let wake_lock = global.as_obj(); let _ = env.call_method(wake_lock, "release", "()V", &[]); } + // Release the proximity wake lock so normal screen-off behaviour + // is restored (the screen will time out as usual). + let mut prox_guard = PROXIMITY_WAKE_LOCK.lock().unwrap(); + if let Some(prox_global) = prox_guard.take() { + let prox_lock = prox_global.as_obj(); + let _ = env.call_method(prox_lock, "release", "()V", &[]); + } // Restore normal lock-screen behaviour. env.call_method(&activity, "setShowWhenLocked", "(Z)V", &[JValue::Bool(0u8)]) .map_err(|e| format!("setShowWhenLocked(false): {e}"))?; From f65d17a3f3eb6742f577db658e8ad4abdd39001b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:26:46 +0000 Subject: [PATCH 2/2] fix: avoid clippy cast_precision_loss in HoldDeleteButton progress calc Agent-Logs-Url: https://github.com/gfauredev/LogOut/sessions/ec2dd9cd-a427-4c82-b793-79c65466f9bb Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com> --- src/components/hold_delete.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/hold_delete.rs b/src/components/hold_delete.rs index 24d25729..f0642b5b 100644 --- a/src/components/hold_delete.rs +++ b/src/components/hold_delete.rs @@ -6,6 +6,9 @@ use dioxus_i18n::t; /// Number of 100 ms ticks that must elapse while the button is held before the /// delete action fires (30 × 100 ms = 3 s). const HOLD_STEPS: u32 = 30; +/// `HOLD_STEPS` as `f32` for progress computations; no precision loss for this +/// small value. +const HOLD_STEPS_F32: f32 = 30.0; /// Duration of each tick in milliseconds. const HOLD_TICK_MS: u32 = 100; /// SVG viewBox half-side (the SVG is `RING_SIZE × RING_SIZE`). @@ -52,7 +55,9 @@ pub fn HoldDeleteButton(on_delete: EventHandler<()>, title: String) -> Element { let hint = hint_msg.clone(); let mut toast = consume_context::().0; spawn(async move { - for step in 1..=HOLD_STEPS { + let increment = 1.0_f32 / HOLD_STEPS_F32; + let mut cur_progress = 0.0_f32; + for _ in 0..HOLD_STEPS { sleep_ms(HOLD_TICK_MS).await; if *gen.peek() != next { // Released early – show the hint toast. @@ -60,7 +65,8 @@ pub fn HoldDeleteButton(on_delete: EventHandler<()>, title: String) -> Element { progress.set(0.0); return; } - progress.set(step as f32 / HOLD_STEPS as f32); + cur_progress += increment; + progress.set(cur_progress); } // Full 3 s elapsed – fire the delete action. if *gen.peek() == next {