Build 42: block over-balance sends + roomier search icon

Guard against paying more than the spendable balance at every entry: the Pay
tab, the send-flow amount step, and the Review screen all render the amount in
red with "You don't have enough grin" and refuse to proceed, so a payment that
would only fail later at the node is stopped up front. On Android the phone
gives a short error buzz on the blocked tap — new VIBRATE bridge
(PlatformCallbacks::vibrate_error -> MainActivity.vibrateError); no-op on
desktop. Grim silently clamped the amount and showed nothing.

Also enlarge and vertically center the magnifier in the "Send to" search field
(an 18px muted label became a 22px centered glyph).
This commit is contained in:
2ro
2026-06-13 02:51:29 -04:00
parent 9d74d6fac5
commit 2e8829ef83
6 changed files with 133 additions and 19 deletions
+1
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
@@ -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;
+4
View File
@@ -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! {
+4
View File
@@ -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) {}
}
+35 -7
View File
@@ -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::<f64>() {
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);
}
}
},
);
+74 -12
View File
@@ -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::<f64>() {
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