Skip to content

Commit a843651

Browse files
Copilotgfauredev
andcommitted
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>
1 parent f3d2924 commit a843651

9 files changed

Lines changed: 178 additions & 40 deletions

File tree

assets/_component.scss

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,4 +420,28 @@ ul.results {
420420
opacity: 1;
421421
transform: translateX(-50%) translateY(0);
422422
}
423+
}
424+
425+
// ── Hold-to-delete button ──────────────────────────────────────────────────
426+
.hold-del {
427+
position: relative;
428+
display: inline-flex;
429+
align-items: center;
430+
justify-content: center;
431+
432+
svg.hold-del-ring {
433+
position: absolute;
434+
inset: 0;
435+
width: 100%;
436+
height: 100%;
437+
transform: rotate(-90deg);
438+
pointer-events: none;
439+
440+
circle {
441+
fill: none;
442+
stroke: var(--less);
443+
stroke-width: 3;
444+
transition: stroke-dashoffset 0.1s linear;
445+
}
446+
}
423447
}

assets/en.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ session-repeat-title = Start a new session based on this one
1212
session-repeat-weekday-title = Repeat same-weekday session
1313
session-delete-title = Delete session
1414
session-show-more = +{ $count } more
15+
hold-to-delete-hint = Hold for 3s to delete
1516
session-delete-confirm = Delete this session?
1617
session-delete-confirm-btn = 🗑️ Delete
1718
cancel-btn = ❌ Cancel

assets/es.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ session-repeat-title = Iniciar nueva sesión basada en esta
1212
session-repeat-weekday-title = Repetir la sesión del mismo día de la semana
1313
session-delete-title = Eliminar sesión
1414
session-show-more = +{ $count } más
15+
hold-to-delete-hint = Mantener 3s para eliminar
1516
session-delete-confirm = ¿Eliminar esta sesión?
1617
session-delete-confirm-btn = 🗑️ Eliminar
1718
cancel-btn = ❌ Cancelar

assets/fr.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ session-repeat-title = Démarrer une nouvelle séance basée sur celle-ci
1212
session-repeat-weekday-title = Répéter la séance du même jour de la semaine
1313
session-delete-title = Supprimer la séance
1414
session-show-more = +{ $count } autres
15+
hold-to-delete-hint = Maintenir 3s pour supprimer
1516
session-delete-confirm = Supprimer cette séance ?
1617
session-delete-confirm-btn = 🗑️ Supprimer
1718
cancel-btn = ❌ Annuler

src/components/completed_exercise_log.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use super::session_exercise_form::ExerciseInputForm;
2+
use crate::components::HoldDeleteButton;
23
use crate::models::{
34
format_time, parse_distance_km, parse_duration_seconds, parse_weight_kg, Category, ExerciseLog,
45
Force, Weight, WorkoutSession, HG_PER_KG, M_PER_KM,
@@ -79,15 +80,13 @@ pub fn CompletedExerciseLog(
7980
title: t!("log-edit-title"),
8081
"✏️"
8182
}
82-
button {
83-
class: "del",
84-
title: t!("log-delete-title"),
85-
onclick: move |_| {
83+
HoldDeleteButton {
84+
title: t!("log-delete-title").to_string(),
85+
on_delete: move |()| {
8686
let mut current_session = session.read().clone();
8787
current_session.exercise_logs.remove(idx);
8888
storage::save_session(current_session);
8989
},
90-
"🗑️"
9190
}
9291
}
9392
}

src/components/hold_delete.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
use crate::utils::sleep_ms;
2+
use crate::ToastSignal;
3+
use dioxus::prelude::*;
4+
use dioxus_i18n::t;
5+
6+
/// Number of 100 ms ticks that must elapse while the button is held before the
7+
/// delete action fires (30 × 100 ms = 3 s).
8+
const HOLD_STEPS: u32 = 30;
9+
/// Duration of each tick in milliseconds.
10+
const HOLD_TICK_MS: u32 = 100;
11+
/// SVG viewBox half-side (the SVG is `RING_SIZE × RING_SIZE`).
12+
const RING_SIZE: f32 = 44.0;
13+
/// Circle radius used for the progress ring (leaves room for the stroke).
14+
const RING_RADIUS: f32 = 19.0;
15+
/// Stroke-dasharray / full circumference of the progress ring.
16+
const RING_CIRC: f32 = 2.0 * std::f32::consts::PI * RING_RADIUS; // ≈ 119.4
17+
18+
/// A delete button that requires the user to hold it for 3 seconds before
19+
/// firing `on_delete`. While the button is held a circular SVG progress ring
20+
/// fills around it. If the button is released early a toast hint is shown.
21+
#[component]
22+
pub fn HoldDeleteButton(on_delete: EventHandler<()>, title: String) -> Element {
23+
let mut progress = use_signal(|| 0.0f32);
24+
// Generation counter: incremented on each press and on each early release.
25+
// The spawned task captures its generation and exits as soon as it drifts.
26+
let mut gen = use_signal(|| 0u32);
27+
28+
let hint_msg = t!("hold-to-delete-hint").to_string();
29+
30+
let offset = RING_CIRC * (1.0 - *progress.read());
31+
32+
rsx! {
33+
div { class: "hold-del",
34+
svg {
35+
class: "hold-del-ring",
36+
view_box: "0 0 {RING_SIZE} {RING_SIZE}",
37+
"aria-hidden": "true",
38+
circle {
39+
cx: "{RING_SIZE / 2.0}",
40+
cy: "{RING_SIZE / 2.0}",
41+
r: "{RING_RADIUS}",
42+
"stroke-dasharray": "{RING_CIRC}",
43+
"stroke-dashoffset": "{offset}",
44+
}
45+
}
46+
button {
47+
class: "del",
48+
title,
49+
onpointerdown: move |_| {
50+
let next = gen.peek().wrapping_add(1);
51+
gen.set(next);
52+
let hint = hint_msg.clone();
53+
let mut toast = consume_context::<ToastSignal>().0;
54+
spawn(async move {
55+
for step in 1..=HOLD_STEPS {
56+
sleep_ms(HOLD_TICK_MS).await;
57+
if *gen.peek() != next {
58+
// Released early – show the hint toast.
59+
toast.write().push_back(hint);
60+
progress.set(0.0);
61+
return;
62+
}
63+
progress.set(step as f32 / HOLD_STEPS as f32);
64+
}
65+
// Full 3 s elapsed – fire the delete action.
66+
if *gen.peek() == next {
67+
on_delete.call(());
68+
}
69+
progress.set(0.0);
70+
});
71+
},
72+
onpointerup: move |_| {
73+
let next = gen.peek().wrapping_add(1);
74+
gen.set(next);
75+
},
76+
onpointerleave: move |_| {
77+
let next = gen.peek().wrapping_add(1);
78+
gen.set(next);
79+
},
80+
"🗑️"
81+
}
82+
}
83+
}
84+
}

src/components/home.rs

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::components::{ActiveTab, BottomNav, SessionView};
1+
use crate::components::{ActiveTab, BottomNav, HoldDeleteButton, SessionView};
22
use crate::models::{format_time, WorkoutSession};
33
use crate::services::{exercise_db, storage};
44
use crate::{ExerciseSearchSignal, Route};
@@ -185,7 +185,6 @@ pub fn Home() -> Element {
185185
#[component]
186186
fn SessionCard(session: WorkoutSession, on_delete: EventHandler<String>) -> Element {
187187
const MAX_VISIBLE: usize = 9;
188-
let mut show_delete_confirm = use_signal(|| false);
189188
let mut show_all_exercises = use_signal(|| false);
190189
let mut show_notes = use_signal(|| false);
191190
let session_id = session.id.clone();
@@ -272,11 +271,12 @@ fn SessionCard(session: WorkoutSession, on_delete: EventHandler<String>) -> Elem
272271
"🔁"
273272
}
274273
}
275-
button {
276-
class: "del",
277-
onclick: move |_| show_delete_confirm.set(true),
278-
title: t!("session-delete-title"),
279-
"🗑️"
274+
HoldDeleteButton {
275+
title: t!("session-delete-title").to_string(),
276+
on_delete: move |()| {
277+
storage::delete_session(&session_id);
278+
on_delete.call(session_id.clone());
279+
},
280280
}
281281
}
282282
if !unique_exercises.is_empty() {
@@ -314,34 +314,6 @@ fn SessionCard(session: WorkoutSession, on_delete: EventHandler<String>) -> Elem
314314
}
315315
}
316316
}
317-
if *show_delete_confirm.read() {
318-
div {
319-
class: "backdrop",
320-
onclick: move |_| show_delete_confirm.set(false),
321-
}
322-
dialog { open: true, onclick: move |evt| evt.stop_propagation(),
323-
p { {t!("session-delete-confirm")} }
324-
div {
325-
button {
326-
onclick: {
327-
let id = session_id.clone();
328-
move |_| {
329-
storage::delete_session(&id);
330-
on_delete.call(id.clone());
331-
show_delete_confirm.set(false);
332-
}
333-
},
334-
class: "del label",
335-
{t!("session-delete-confirm-btn")}
336-
}
337-
button {
338-
onclick: move |_| show_delete_confirm.set(false),
339-
class: "back label",
340-
{t!("cancel-btn")}
341-
}
342-
}
343-
}
344-
}
345317
}
346318
}
347319
}

src/components/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod edit_exercise;
77
pub mod exercise_card;
88
pub mod exercise_form_fields;
99
pub mod exercises;
10+
pub mod hold_delete;
1011
pub mod home;
1112
pub mod more;
1213
mod session_exercise_form;
@@ -19,5 +20,6 @@ pub use completed_exercise_log::CompletedExerciseLog;
1920
pub use edit_exercise::EditExercise;
2021
pub use exercise_card::ExerciseCard;
2122
pub use exercises::Exercises;
23+
pub use hold_delete::HoldDeleteButton;
2224
pub use home::Home;
2325
pub use more::More;

src/services/wake_lock.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,17 @@ fn acquire_android_wake_lock() {
170170
static SCREEN_WAKE_LOCK: std::sync::Mutex<Option<jni::objects::GlobalRef>> =
171171
std::sync::Mutex::new(None);
172172

173+
/// JNI global reference to the `PROXIMITY_SCREEN_OFF_WAKE_LOCK` held while a
174+
/// session is active over the lock screen.
175+
///
176+
/// When this lock is held, Android's power manager monitors the proximity
177+
/// sensor and automatically turns off the screen (and disables touch input)
178+
/// when the phone is placed in a pocket or bag, preventing accidental touches.
179+
/// The screen turns back on as soon as proximity is no longer detected.
180+
#[cfg(target_os = "android")]
181+
static PROXIMITY_WAKE_LOCK: std::sync::Mutex<Option<jni::objects::GlobalRef>> =
182+
std::sync::Mutex::new(None);
183+
173184
/// Configure Android lock-screen behaviour based on whether a session is active.
174185
///
175186
/// When `active` is `true`:
@@ -279,6 +290,42 @@ pub fn set_active_session_lock_screen(active: bool) {
279290
.new_global_ref(&wake_lock)
280291
.map_err(|e| format!("new_global_ref: {e}"))?;
281292
*guard = Some(global);
293+
294+
// Acquire PROXIMITY_SCREEN_OFF_WAKE_LOCK (0x20) so that
295+
// Android automatically turns off the screen (and disables
296+
// touch input) whenever the proximity sensor fires — e.g.
297+
// when the phone is slipped into a pocket — preventing
298+
// accidental touches while the session is running.
299+
let mut prox_guard = PROXIMITY_WAKE_LOCK.lock().unwrap();
300+
if prox_guard.is_none() {
301+
// PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK = 32 (0x20)
302+
let prox_tag = env
303+
.new_string("logout:proximity")
304+
.map_err(|e| format!("new_string proximity: {e}"))?;
305+
let prox_lock = env
306+
.call_method(
307+
&pm,
308+
"newWakeLock",
309+
"(ILjava/lang/String;)Landroid/os/PowerManager$WakeLock;",
310+
&[JValue::Int(0x20i32), (&prox_tag).into()],
311+
)
312+
.map_err(|e| format!("newWakeLock proximity: {e}"))?
313+
.l()
314+
.map_err(|e| format!("ProximityWakeLock obj: {e}"))?;
315+
env.call_method(
316+
&prox_lock,
317+
"setReferenceCounted",
318+
"(Z)V",
319+
&[JValue::Bool(0u8)],
320+
)
321+
.map_err(|e| format!("setReferenceCounted proximity: {e}"))?;
322+
env.call_method(&prox_lock, "acquire", "()V", &[])
323+
.map_err(|e| format!("acquire proximity: {e}"))?;
324+
let prox_global = env
325+
.new_global_ref(&prox_lock)
326+
.map_err(|e| format!("new_global_ref proximity: {e}"))?;
327+
*prox_guard = Some(prox_global);
328+
}
282329
}
283330
} else {
284331
let mut guard = SCREEN_WAKE_LOCK.lock().unwrap();
@@ -287,6 +334,13 @@ pub fn set_active_session_lock_screen(active: bool) {
287334
let wake_lock = global.as_obj();
288335
let _ = env.call_method(wake_lock, "release", "()V", &[]);
289336
}
337+
// Release the proximity wake lock so normal screen-off behaviour
338+
// is restored (the screen will time out as usual).
339+
let mut prox_guard = PROXIMITY_WAKE_LOCK.lock().unwrap();
340+
if let Some(prox_global) = prox_guard.take() {
341+
let prox_lock = prox_global.as_obj();
342+
let _ = env.call_method(prox_lock, "release", "()V", &[]);
343+
}
290344
// Restore normal lock-screen behaviour.
291345
env.call_method(&activity, "setShowWhenLocked", "(Z)V", &[JValue::Bool(0u8)])
292346
.map_err(|e| format!("setShowWhenLocked(false): {e}"))?;

0 commit comments

Comments
 (0)