diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index dfbef0b0..1321f03a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
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 59ab13f2..d23ee7fa 100644
--- a/android/app/src/main/java/mw/gri/android/MainActivity.java
+++ b/android/app/src/main/java/mw/gri/android/MainActivity.java
@@ -510,6 +510,21 @@ public class MainActivity extends GameActivity {
startActivity(Intent.createChooser(intent, "Share data"));
}
+ // Called from native code to play a short "error" haptic (rejected payment).
+ public void vibrateError() {
+ Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
+ if (vibrator == null || !vibrator.hasVibrator()) {
+ return;
+ }
+ // Two short pulses read as "no" / rejected, distinct from a tap.
+ long[] pattern = new long[]{0, 40, 70, 40};
+ if (Build.VERSION.SDK_INT >= 26) {
+ vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1));
+ } else {
+ vibrator.vibrate(pattern, -1);
+ }
+ }
+
// 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 77346240..b3141397 100644
--- a/src/gui/platform/android/mod.rs
+++ b/src/gui/platform/android/mod.rs
@@ -207,6 +207,10 @@ impl PlatformCallbacks for Android {
&[JValue::Bool(white as u8)],
);
}
+
+ fn vibrate_error(&self) {
+ let _ = self.call_java_method("vibrateError", "()V", &[]);
+ }
}
lazy_static! {
diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs
index 3bc207be..633ab9fd 100644
--- a/src/gui/platform/mod.rs
+++ b/src/gui/platform/mod.rs
@@ -47,4 +47,8 @@ pub trait PlatformCallbacks {
/// Set the status-bar icon color to contrast the current theme. `white` =
/// light icons (for a dark background). No-op off Android.
fn set_status_bar_white_icons(&self, _white: bool) {}
+
+ /// Play a short "error" haptic (e.g. a rejected over-balance payment).
+ /// No-op off Android.
+ fn vibrate_error(&self) {}
}
diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs
index 025e7c0b..a1b548d6 100644
--- a/src/gui/views/goblin/mod.rs
+++ b/src/gui/views/goblin/mod.rs
@@ -323,7 +323,7 @@ impl GoblinWalletView {
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab {
Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop),
- Tab::Pay => self.pay_ui(ui, wallet),
+ Tab::Pay => self.pay_ui(ui, wallet, cb),
Tab::Activity => self.activity_ui(ui, wallet, cb),
Tab::Receive => self.receive_ui(ui, wallet, cb),
Tab::Me => self.me_ui(ui, wallet, cb),
@@ -806,7 +806,7 @@ impl GoblinWalletView {
}
/// Pay tab: amount-first combined pay/request surface.
- fn pay_ui(&mut self, ui: &mut egui::Ui, _wallet: &Wallet) {
+ fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
ui.add_space(8.0);
ui.horizontal(|ui| {
@@ -848,8 +848,22 @@ impl GoblinWalletView {
self.pay_amount.clone()
};
let tall = ui.available_height() > 560.0;
+ // Block paying more than the spendable balance: red amount + a message
+ // + an error buzz on tap (Request is unguarded — you can request more
+ // than you hold).
+ let spendable = wallet
+ .get_data()
+ .map(|d| d.info.amount_currently_spendable)
+ .unwrap_or(0);
+ let over = grin_core::core::amount_from_hr_string(&self.pay_amount)
+ .map(|a| a > spendable)
+ .unwrap_or(false);
ui.add_space(if tall { 56.0 } else { 24.0 });
- w::amount_text_centered(ui, &display, 76.0);
+ if over {
+ w::amount_text_centered_ink(ui, &display, 76.0, t.neg, t.neg);
+ } else {
+ w::amount_text_centered(ui, &display, 76.0);
+ }
if let Ok(grin) = display.parse::() {
if let Some(preview) = pairing_preview(grin) {
ui.add_space(6.0);
@@ -862,6 +876,16 @@ impl GoblinWalletView {
});
}
}
+ if over {
+ ui.add_space(6.0);
+ ui.vertical_centered(|ui| {
+ ui.label(
+ RichText::new("You don't have enough grin")
+ .font(FontId::new(14.0, fonts::regular()))
+ .color(t.neg),
+ );
+ });
+ }
ui.add_space(if tall { 32.0 } else { 16.0 });
// Numpad at narrow (mobile-shell) widths, typed input on the wide
@@ -912,10 +936,14 @@ impl GoblinWalletView {
)),
|ui| {
if w::big_action(ui, "Pay", false).clicked() && valid {
- let mut f = SendFlow::default();
- f.prefill_amount(self.pay_amount.clone());
- self.pay_amount.clear();
- self.send = Some(f);
+ if over {
+ cb.vibrate_error();
+ } else {
+ let mut f = SendFlow::default();
+ f.prefill_amount(self.pay_amount.clone());
+ self.pay_amount.clear();
+ self.send = Some(f);
+ }
}
},
);
diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs
index 20038b26..c6a4025f 100644
--- a/src/gui/views/goblin/send.rs
+++ b/src/gui/views/goblin/send.rs
@@ -278,10 +278,14 @@ impl SendFlow {
}
.show(ui, |ui| {
ui.horizontal(|ui| {
- ui.label(
- RichText::new(MAGNIFYING_GLASS)
- .font(FontId::new(18.0, fonts::regular()))
- .color(t.surface_text_mute),
+ let (icon_rect, _) =
+ ui.allocate_exact_size(egui::Vec2::new(24.0, 42.0), egui::Sense::hover());
+ ui.painter().text(
+ icon_rect.center(),
+ egui::Align2::CENTER_CENTER,
+ MAGNIFYING_GLASS,
+ FontId::new(22.0, fonts::regular()),
+ t.surface_text_dim,
);
ui.add_space(8.0);
let mut te = TextEdit::new(egui::Id::from("send_search"))
@@ -716,6 +720,16 @@ impl SendFlow {
}
let recipient = self.recipient.clone().unwrap();
+ // Block sends over the spendable balance: red amount + a message + an
+ // error buzz on tap, rather than letting it fail later at the node.
+ let spendable = wallet
+ .get_data()
+ .map(|d| d.info.amount_currently_spendable)
+ .unwrap_or(0);
+ let over = amount_from_hr_string(&self.amount)
+ .map(|a| a > spendable)
+ .unwrap_or(false);
+
// Recipient chip, centered per the design.
let name_label = format!("To {}", recipient.name);
let name_galley = ui.painter().layout_no_wrap(
@@ -743,7 +757,11 @@ impl SendFlow {
} else {
self.amount.clone()
};
- w::amount_text_centered(ui, &display, 64.0);
+ if over {
+ w::amount_text_centered_ink(ui, &display, 64.0, t.neg, t.neg);
+ } else {
+ w::amount_text_centered(ui, &display, 64.0);
+ }
if let Ok(grin) = display.parse::() {
if let Some(preview) = super::pairing_preview(grin) {
ui.add_space(6.0);
@@ -756,6 +774,16 @@ impl SendFlow {
});
}
}
+ if over {
+ ui.add_space(6.0);
+ ui.vertical_centered(|ui| {
+ ui.label(
+ RichText::new("You don't have enough grin")
+ .font(FontId::new(14.0, fonts::regular()))
+ .color(t.neg),
+ );
+ });
+ }
ui.add_space(16.0);
// Quick chips.
@@ -815,7 +843,11 @@ impl SendFlow {
.unwrap_or(false);
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, "Review", false).clicked() {
- self.stage = Stage::Review;
+ if over {
+ cb.vibrate_error();
+ } else {
+ self.stage = Stage::Review;
+ }
}
});
false
@@ -836,6 +868,18 @@ impl SendFlow {
let amount = self.amount.clone();
let hero_tex = tex_for(avatars, ui.ctx(), wallet, &recipient.name);
+ // Over-balance guard: every path that reaches Review (including the
+ // Pay-tab / scan prefill that jumps straight here, skipping the amount
+ // step) must not offer a completable send for more than the spendable
+ // balance. Re-checked each frame so a mid-flow balance drop disables it.
+ let spendable = wallet
+ .get_data()
+ .map(|d| d.info.amount_currently_spendable)
+ .unwrap_or(0);
+ let over = amount_from_hr_string(&amount)
+ .map(|a| a > spendable)
+ .unwrap_or(false);
+
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.add_space(8.0);
@@ -871,16 +915,34 @@ impl SendFlow {
w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor");
ui.add_space(16.0);
- if self.hold.ui(ui, "Hold to send") {
- self.dispatch(wallet);
- self.stage = Stage::Sending;
+ if over {
+ ui.vertical_centered(|ui| {
+ ui.label(
+ RichText::new("You don't have enough grin")
+ .font(FontId::new(14.0, fonts::regular()))
+ .color(t.neg),
+ );
+ });
+ ui.add_space(8.0);
}
+ // Greyed out while over balance; the `&& !over` also refuses the send in
+ // case the hold widget ignores the disabled state.
+ ui.add_enabled_ui(!over, |ui| {
+ if self.hold.ui(ui, "Hold to send") && !over {
+ self.dispatch(wallet);
+ self.stage = Stage::Sending;
+ }
+ });
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
- RichText::new("Press and hold to confirm")
- .font(FontId::new(12.0, fonts::regular()))
- .color(t.text_mute),
+ RichText::new(if over {
+ "Go back and lower the amount"
+ } else {
+ "Press and hold to confirm"
+ })
+ .font(FontId::new(12.0, fonts::regular()))
+ .color(t.text_mute),
);
});
false