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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user