Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions assets/_component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
1 change: 1 addition & 0 deletions assets/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions assets/es.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions assets/fr.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions src/components/completed_exercise_log.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
},
"🗑️"
}
}
}
Expand Down
90 changes: 90 additions & 0 deletions src/components/hold_delete.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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;
/// `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`).
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::<ToastSignal>().0;
spawn(async move {
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.
toast.write().push_back(hint);
progress.set(0.0);
return;
}
cur_progress += increment;
progress.set(cur_progress);
}
// 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);
},
"🗑️"
}
}
}
}
42 changes: 7 additions & 35 deletions src/components/home.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -185,7 +185,6 @@ pub fn Home() -> Element {
#[component]
fn SessionCard(session: WorkoutSession, on_delete: EventHandler<String>) -> 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();
Expand Down Expand Up @@ -272,11 +271,12 @@ fn SessionCard(session: WorkoutSession, on_delete: EventHandler<String>) -> 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() {
Expand Down Expand Up @@ -314,34 +314,6 @@ fn SessionCard(session: WorkoutSession, on_delete: EventHandler<String>) -> 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")}
}
}
}
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
54 changes: 54 additions & 0 deletions src/services/wake_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ fn acquire_android_wake_lock() {
static SCREEN_WAKE_LOCK: std::sync::Mutex<Option<jni::objects::GlobalRef>> =
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<Option<jni::objects::GlobalRef>> =
std::sync::Mutex::new(None);

/// Configure Android lock-screen behaviour based on whether a session is active.
///
/// When `active` is `true`:
Expand Down Expand Up @@ -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();
Expand All @@ -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}"))?;
Expand Down
Loading