1
0
forked from GRIN/grim

Build 60: Scan-to-pay gets a Scan | My Code toggle, yellow QR, native share

Cash App-style scan screen: a segmented Scan | My Code control (new w::segmented)
over either the camera or your own payment QR. My Code shows the @handle above a
big nprofile QR with the Goblin mark nested in a YELLOW center (was white — same
19% footprint, yellow reads as light to a scanner so the High-ECC recovery is
unchanged), and a native Share button. Added share_text to PlatformCallbacks
(Android ACTION_SEND text/plain via a new shareText JNI method; desktop falls
back to clipboard) to share the npub + nprofile link.
This commit is contained in:
2ro
2026-06-13 23:15:51 -04:00
parent 3ebae8807c
commit ea70923e83
5 changed files with 181 additions and 17 deletions
@@ -510,6 +510,15 @@ public class MainActivity extends GameActivity {
startActivity(Intent.createChooser(intent, "Share data"));
}
// Called from native code to share plain text (e.g. a payment link) via the
// system share sheet.
public void shareText(String text) {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, text);
intent.setType("text/plain");
startActivity(Intent.createChooser(intent, "Share"));
}
// Called from native code to play a short "error" haptic (rejected payment).
public void vibrateError() {
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
+13
View File
@@ -158,6 +158,19 @@ impl PlatformCallbacks for Android {
Ok(())
}
fn share_text(&self, text: String) {
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let Ok(arg_value) = env.new_string(text) else {
return;
};
let _ = self.call_java_method(
"shareText",
"(Ljava/lang/String;)V",
&[JValue::Object(&JObject::from(arg_value))],
);
}
fn pick_file(&self) -> Option<String> {
// Clear previous result.
let mut w_path = PICKED_FILE_PATH.write();
+6
View File
@@ -32,6 +32,12 @@ pub trait PlatformCallbacks {
fn can_switch_camera(&self) -> bool;
fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
/// Share plain text via the platform's native share sheet (e.g. a payment
/// link). Defaults to copying to the clipboard on platforms without a share
/// sheet (desktop).
fn share_text(&self, text: String) {
self.copy_string_to_buffer(text);
}
fn pick_file(&self) -> Option<String>;
/// Native picker filtered to picture files; defaults to the plain picker
/// on platforms without filter support (magic-byte sniffing protects).
+102 -12
View File
@@ -18,7 +18,7 @@ use eframe::epaint::FontId;
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
use grin_core::core::amount_from_hr_string;
use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, USERS};
use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, SHARE, USERS};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::types::QrScanResult;
@@ -58,6 +58,14 @@ enum Stage {
Failed,
}
/// The two halves of the scan-to-pay screen: the camera, or your own code.
#[derive(Clone, Copy, PartialEq, Default)]
enum ScanTab {
#[default]
Scan,
MyCode,
}
/// A resolved recipient.
#[derive(Clone)]
struct Recipient {
@@ -119,6 +127,10 @@ pub struct SendFlow {
scan: Option<CameraContent>,
/// Start scanning on the next recipient frame (entry from header icon).
start_scan: bool,
/// Which half of the scan-to-pay screen is showing (camera vs. own code).
scan_tab: ScanTab,
/// The scan-to-pay screen is open (gates camera + My Code).
scan_open: bool,
/// Request mode: issue an Invoice1 to the recipient (ask them to pay) rather
/// than sending them money. Reuses the recipient picker; no balance guard.
request: bool,
@@ -145,6 +157,8 @@ impl Default for SendFlow {
confirm_unverified: None,
scan: None,
start_scan: false,
scan_tab: ScanTab::Scan,
scan_open: false,
request: false,
receipt_npub: None,
}
@@ -264,15 +278,16 @@ impl SendFlow {
) -> bool {
let t = theme::tokens();
// Header-icon entry arms the scanner before the first frame.
// Header-icon entry opens the scan-to-pay screen on the camera tab.
if self.start_scan {
self.start_scan = false;
cb.start_camera();
self.scan = Some(CameraContent::default());
self.scan_open = true;
self.scan_tab = ScanTab::Scan;
}
// Scanner mode: the camera feed replaces the picker until cancelled.
if self.scan.is_some() {
// Scan-to-pay screen: a Scan | My Code toggle over the camera or your own
// payment QR. Replaces the picker until closed.
if self.scan_open {
if self.back_header(
ui,
if self.request {
@@ -283,9 +298,36 @@ impl SendFlow {
) {
cb.stop_camera();
self.scan = None;
self.scan_open = false;
return false;
}
self.scan_ui(ui, wallet, cb);
let sel = if self.scan_tab == ScanTab::Scan { 0 } else { 1 };
if let Some(i) = w::segmented(ui, &["Scan", "My Code"], sel) {
self.scan_tab = if i == 0 {
ScanTab::Scan
} else {
ScanTab::MyCode
};
}
ui.add_space(14.0);
match self.scan_tab {
ScanTab::Scan => {
// Keep the camera running on this tab.
if self.scan.is_none() {
cb.start_camera();
self.scan = Some(CameraContent::default());
}
self.scan_ui(ui, wallet, cb);
}
ScanTab::MyCode => {
// No camera needed while showing our own code.
if self.scan.is_some() {
cb.stop_camera();
self.scan = None;
}
self.my_code_ui(ui, wallet, cb);
}
}
return false;
}
@@ -559,6 +601,9 @@ impl SendFlow {
if let Some(result) = result {
cb.stop_camera();
self.scan = None;
// A captured code returns to the picker, where the resolved recipient
// (or an error) shows.
self.scan_open = false;
// Only plain text payloads can name a recipient — never echo
// seed words or slatepack contents into the search box.
match &result {
@@ -583,15 +628,60 @@ impl SendFlow {
ui.add_space(14.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Point at a goblin receive QR")
RichText::new("Position a goblin code in view to activate")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(14.0);
if w::big_action(ui, "Cancel", false).clicked() {
cb.stop_camera();
self.scan = None;
}
/// "My Code" half of the scan-to-pay screen: our own payment QR (the nostr
/// nprofile) with the Goblin mark nested in the middle, for someone to scan
/// and pay us. Mirrors the Receive card, trimmed to the essentials.
fn my_code_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
let (handle, npub, nprofile) = wallet
.nostr_service()
.map(|s| {
let nip05 = s.identity.read().nip05.clone();
let handle = nip05
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| short_npub(&s.public_key().to_hex()));
(handle, s.npub(), s.nprofile())
})
.unwrap_or_else(|| ("".to_string(), String::new(), String::new()));
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(10.0);
ui.label(
RichText::new(&handle)
.font(FontId::new(22.0, fonts::bold()))
.color(t.surface_text),
);
ui.add_space(2.0);
ui.label(
RichText::new("Scan to pay me")
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(18.0);
let uri = format!("nostr:{}", nprofile);
w::qr_code(ui, &uri, 248.0);
ui.add_space(10.0);
});
});
ui.add_space(12.0);
if w::big_action(ui, &format!("{} Share", SHARE), false).clicked() {
// Share the full nostr identity (npub + relay hints), with the bare
// npub as a fallback line, via the platform's native share sheet.
let link = if nprofile.is_empty() {
npub.clone()
} else {
format!("nostr:{}", nprofile)
};
let msg = format!("Pay me on Goblin — {}\n{}\nnpub: {}", handle, link, npub);
cb.share_text(msg);
}
}
+51 -5
View File
@@ -179,6 +179,49 @@ pub fn toggle(ui: &mut Ui, on: bool) -> Response {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A segmented control (e.g. `["Scan", "My Code"]`). Highlights `selected`;
/// returns `Some(i)` when a different segment is tapped.
pub fn segmented(ui: &mut Ui, labels: &[&str], selected: usize) -> Option<usize> {
let t = theme::tokens();
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::hover());
ui.painter()
.rect_filled(rect, CornerRadius::same(22), t.surface2);
let inner = rect.shrink(4.0);
let seg_w = inner.width() / labels.len().max(1) as f32;
let mut clicked = None;
for (i, label) in labels.iter().enumerate() {
let seg = egui::Rect::from_min_size(
inner.min + Vec2::new(i as f32 * seg_w, 0.0),
Vec2::new(seg_w, inner.height()),
);
let resp = ui.interact(seg, ui.id().with(("seg", i)), Sense::click());
let on = i == selected;
if on {
ui.painter()
.rect_filled(seg, CornerRadius::same(18), t.accent);
}
ui.painter().text(
seg.center(),
egui::Align2::CENTER_CENTER,
*label,
FontId::new(
15.0,
if on {
fonts::semibold()
} else {
fonts::regular()
},
),
if on { t.accent_ink } else { t.surface_text_dim },
);
if resp.clicked() && !on {
clicked = Some(i);
}
resp.on_hover_cursor(egui::CursorIcon::PointingHand);
}
clicked
}
/// Big primary/secondary action button (56px, radius 14).
pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
let t = theme::tokens();
@@ -344,16 +387,19 @@ pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
}
}
}
// Goblin mark on a plate-colored backing square in the center. 19% of
// the code: at 26%, zbar-class scanners fail on the glyph (rqrr and
// ZXing-class tolerate it); 19% passes everything probed.
// Goblin mark on a yellow backing square in the center, same 19% footprint
// the white version was tuned to (at 26%, zbar-class scanners fail on the
// glyph; 19% passes everything probed). Yellow's luminance reads as "light"
// to a scanner just like white, so the obscured center is recovered by the
// High ECC exactly as before — only the colour changes.
let t = theme::tokens();
let backing = size * 0.19;
let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing));
ui.painter()
.rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), plate);
.rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), t.accent);
let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72));
egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg"))
.tint(ink)
.tint(t.accent_ink)
.fit_to_exact_size(m_rect.size())
.paint_at(ui, m_rect);
}