diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index c243988..ba32b08 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -534,6 +534,22 @@ public class MainActivity extends GameActivity { } } + // Called from native code to play a tiny "tick" haptic on a successful copy. + public void vibrateCopy() { + Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || !vibrator.hasVibrator()) { + return; + } + // One short, light tick — a confirmation, not an alert. + if (Build.VERSION.SDK_INT >= 29) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)); + } else if (Build.VERSION.SDK_INT >= 26) { + vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(20); + } + } + // Called from native code to set status-bar icon color to contrast the // in-app theme. white = light icons for a dark background. The app draws // edge-to-edge, so the OS status-bar background is the app's own content; diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index 7b082e9..7552409 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -224,6 +224,10 @@ impl PlatformCallbacks for Android { fn vibrate_error(&self) { let _ = self.call_java_method("vibrateError", "()V", &[]); } + + fn vibrate_copy(&self) { + let _ = self.call_java_method("vibrateCopy", "()V", &[]); + } } lazy_static! { diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 2175325..694fa61 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -57,4 +57,7 @@ pub trait PlatformCallbacks { /// Play a short "error" haptic (e.g. a rejected over-balance payment). /// No-op off Android. fn vibrate_error(&self) {} + + /// Play a tiny "tick" haptic confirming a successful copy. No-op off Android. + fn vibrate_copy(&self) {} } diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 142982a..56a5047 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -110,6 +110,8 @@ pub struct GoblinWalletView { cancel_confirm: Option, /// Outcome of the last manual cancel, shown transiently on the receipt. cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>, + /// Transient "Copied" flash for the settings backup card (npub/keys). + copy_flash: Option, } /// Sub-pages of the Settings tab. @@ -190,6 +192,7 @@ impl Default for GoblinWalletView { avatar_msg: None, cancel_confirm: None, cancel_msg: None, + copy_flash: None, } } } @@ -2023,6 +2026,7 @@ impl GoblinWalletView { }; if w::big_action(ui, &label, false).clicked() && !npub.is_empty() { cb.copy_string_to_buffer(npub.clone()); + cb.vibrate_copy(); self.receive_copied = Some((1, std::time::Instant::now())); } }, @@ -2215,11 +2219,15 @@ impl GoblinWalletView { if !npub.is_empty() { if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) { cb.copy_string_to_buffer(npub.clone()); + cb.vibrate_copy(); + self.copy_flash = Some(std::time::Instant::now()); } // A real backup is the SECRET key (nsec), not the npub. if settings_row_btn(ui, &t!("goblin.settings.backup_nsec"), COPY) { if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { cb.copy_string_to_buffer(nsec); + cb.vibrate_copy(); + self.copy_flash = Some(std::time::Instant::now()); } } // Encrypted backup file: the identity JSON as stored @@ -2233,6 +2241,8 @@ impl GoblinWalletView { let json = serde_json::to_string_pretty(&*s.identity.read()) .unwrap_or_default(); cb.copy_string_to_buffer(json); + cb.vibrate_copy(); + self.copy_flash = Some(std::time::Instant::now()); } } if settings_row_danger( @@ -2253,6 +2263,28 @@ impl GoblinWalletView { } } }); + // Transient confirmation that the copy landed — pairs with the + // haptic tick so the tap feels acknowledged. + if let Some(at) = self.copy_flash { + if at.elapsed().as_secs_f32() < 1.5 { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(format!( + "{} {}", + crate::gui::icons::CHECK, + t!("goblin.receipt.copied") + )) + .font(FontId::new(13.0, fonts::medium())) + .color(t.pos), + ); + }); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(120)); + } else { + self.copy_flash = None; + } + } ui.add_space(6.0); ui.label( RichText::new(t!("goblin.settings.backup_note")) @@ -2389,6 +2421,7 @@ impl GoblinWalletView { if let Some(s) = wallet.nostr_service() { let json = s.store.export_json(&s.npub()); cb.copy_string_to_buffer(json); + cb.vibrate_copy(); } } if settings_row_btn( @@ -3172,6 +3205,7 @@ impl GoblinWalletView { ui.add_space(10.0); if w::big_action(ui, &t!("goblin.settings.sp_copy"), false).clicked() { cb.copy_string_to_buffer(result); + cb.vibrate_copy(); } } ui.add_space(16.0); @@ -3459,6 +3493,7 @@ impl GoblinWalletView { if w::big_action_on_card(ui, &t!("goblin.settings.copy_new_nsec")).clicked() { if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { cb.copy_string_to_buffer(nsec); + cb.vibrate_copy(); } } ui.add_space(8.0); diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 261847e..36242cb 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -537,6 +537,7 @@ impl OnboardingContent { ui.add_space(14.0); } else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() { cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase()); + cb.vibrate_copy(); } if !restore { ui.add_space(14.0);