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 d23ee7f..c243988 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,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); diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index b314139..7b082e9 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -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 { // Clear previous result. let mut w_path = PICKED_FILE_PATH.write(); diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 633ab9f..2175325 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -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) -> 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; /// Native picker filtered to picture files; defaults to the plain picker /// on platforms without filter support (magic-byte sniffing protects). diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 2cd2016..cc534ca 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -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, /// 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); } } diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 4358dc9..697f81e 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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 { + 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); }