diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs new file mode 100644 index 00000000..21235454 --- /dev/null +++ b/src/gui/views/goblin/data.rs @@ -0,0 +1,158 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Activity model: wallet transactions joined with nostr metadata. + +use grin_wallet_libwallet::TxLogEntryType; + +use crate::nostr::{Contact, NostrStore, TxNostrMeta}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTx; + +/// A unified activity entry for the Goblin feed. +pub struct ActivityItem { + pub tx_id: u32, + pub title: String, + pub note: Option, + pub amount: u64, + pub incoming: bool, + pub confirmed: bool, + pub system: bool, + pub hue: usize, + pub time: i64, + /// Counterparty npub hex, when known. + pub npub: Option, +} + +/// Resolve the display title for a contact npub. +pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) { + if let Some(contact) = store.contact(npub) { + (display_name(&contact), contact.hue as usize) + } else { + let hue = usize::from_str_radix(&npub[..2.min(npub.len())], 16).unwrap_or(0) % 7; + (short_npub(npub), hue) + } +} + +/// Display rule: petname → @user (verified goblin.st) → user@domain → npub short. +pub fn display_name(contact: &Contact) -> String { + if let Some(petname) = &contact.petname { + if !petname.is_empty() { + return petname.clone(); + } + } + if let (Some(nip05), Some(_)) = (&contact.nip05, contact.nip05_verified_at) { + if let Some((name, domain)) = nip05.split_once('@') { + if domain == crate::nostr::relays::HOME_NIP05_DOMAIN { + return format!("@{}", name); + } + return nip05.clone(); + } + } + short_npub(&contact.npub) +} + +/// Short npub display (npub1abcd…wxyz) from a hex pubkey. +pub fn short_npub(hex: &str) -> String { + use nostr_sdk::{PublicKey, ToBech32}; + if let Ok(pk) = PublicKey::from_hex(hex) { + if let Ok(npub) = pk.to_bech32() { + if npub.len() > 16 { + return format!("{}…{}", &npub[..9], &npub[npub.len() - 4..]); + } + return npub; + } + } + format!("{}…", &hex[..8.min(hex.len())]) +} + +/// Build the activity feed for a wallet, newest first. +pub fn activity_items(wallet: &Wallet) -> Vec { + let data = match wallet.get_data() { + Some(d) => d, + None => return vec![], + }; + let txs = data.txs.unwrap_or_default(); + let store = wallet.nostr_service().map(|s| s.store.clone()); + let mut items: Vec = txs + .iter() + .map(|tx| build_item(tx, store.as_deref())) + .collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.time)); + items +} + +fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem { + let incoming = matches!( + tx.data.tx_type, + TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase + ); + let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase); + let slate_id = tx.data.tx_slate_id.map(|u| u.to_string()); + let meta: Option = slate_id + .as_ref() + .and_then(|sid| store.and_then(|s| s.tx_meta(sid))); + + let (title, hue) = if system { + ("Mining reward".to_string(), 5) + } else if let Some(meta) = &meta { + store + .map(|s| contact_title(s, &meta.npub)) + .unwrap_or_else(|| (short_npub(&meta.npub), 0)) + } else { + // Fall back to slatepack address counterparty or generic label. + let label = if incoming { + "Received".to_string() + } else { + "Sent".to_string() + }; + (label, (tx.data.id as usize) % 7) + }; + + let note = meta.as_ref().and_then(|m| m.note.clone()); + let time = tx + .data + .confirmation_ts + .or(Some(tx.data.creation_ts)) + .map(|t| t.timestamp()) + .unwrap_or(0); + + ActivityItem { + tx_id: tx.data.id, + title, + note, + amount: tx.amount, + incoming, + confirmed: tx.data.confirmed, + system, + hue, + time, + npub: meta.map(|m| m.npub), + } +} + +/// Recent unique peers for the home strip (most recent first). +pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> { + let store = match wallet.nostr_service() { + Some(s) => s.store.clone(), + None => return vec![], + }; + let mut contacts = store.all_contacts(); + contacts.sort_by_key(|c| std::cmp::Reverse(c.last_paid_at.unwrap_or(c.added_at))); + contacts + .into_iter() + .take(limit) + .map(|c| (display_name(&c), c.hue as usize, c.npub)) + .collect() +} diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs new file mode 100644 index 00000000..2780543b --- /dev/null +++ b/src/gui/views/goblin/mod.rs @@ -0,0 +1,691 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The Goblin Cash App-style wallet surface for an open wallet. + +pub mod data; +pub mod send; +pub mod widgets; + +use eframe::epaint::{FontId, Stroke}; +use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; + +use crate::gui::icons::{CLOCK, COPY, SCAN, USER_CIRCLE, WALLET}; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::theme::{self, fonts}; +use crate::gui::views::wallets::wallet::WalletTransactionsContent; +use crate::gui::views::{Content, View}; +use crate::wallet::Wallet; +use crate::wallet::types::WalletData; + +use self::data::{ActivityItem, activity_items, recent_peers}; +use self::send::SendFlow; +use self::widgets as w; + +/// Goblin navigation tabs. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Home, + Activity, + Receive, + Me, +} + +/// Goblin wallet content view. +pub struct GoblinWalletView { + tab: Tab, + send: Option, + /// Legacy transactions content reused for detail rendering when needed. + txs_content: WalletTransactionsContent, +} + +impl Default for GoblinWalletView { + fn default() -> Self { + Self { + tab: Tab::Home, + send: None, + txs_content: WalletTransactionsContent::new(None), + } + } +} + +impl GoblinWalletView { + /// Whether an overlay flow (send) is active. + pub fn overlay_active(&self) -> bool { + self.send.is_some() + } + + /// Handle a back navigation; returns true if not consumed. + pub fn on_back(&mut self) -> bool { + if self.send.is_some() { + self.send = None; + return false; + } + if self.tab != Tab::Home { + self.tab = Tab::Home; + return false; + } + true + } + + /// Render the full Goblin surface for an open wallet. + pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + + // Send flow takes the full surface when active. + if let Some(send) = &mut self.send { + let done = send.ui(ui, wallet, cb); + if done { + self.send = None; + } + return; + } + + // Bottom tab bar. + let bottom_inset = View::get_bottom_inset(); + egui::TopBottomPanel::bottom("goblin_tabs") + .frame(egui::Frame { + fill: t.bg, + inner_margin: Margin { + left: 8, + right: 8, + top: 8, + bottom: (4.0 + bottom_inset) as i8, + }, + stroke: Stroke::new(1.0, t.line), + ..Default::default() + }) + .show_inside(ui, |ui| { + self.tab_bar_ui(ui, wallet); + }); + + // Central content. + egui::CentralPanel::default() + .frame(egui::Frame { + fill: t.bg, + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 20.0) as i8, + right: (View::get_right_inset() + 20.0) as i8, + top: (View::get_top_inset() + 8.0) as i8, + bottom: 0, + }, + ..Default::default() + }) + .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), + Tab::Activity => self.activity_ui(ui, wallet, cb), + Tab::Receive => self.receive_ui(ui, wallet, cb), + Tab::Me => self.me_ui(ui, wallet, cb), + }); + }); + } + + fn tab_bar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let has_requests = wallet + .nostr_service() + .map(|s| !s.store.pending_requests().is_empty()) + .unwrap_or(false); + let tabs = [ + (Tab::Home, WALLET, "Wallet", false), + (Tab::Activity, CLOCK, "Activity", has_requests), + (Tab::Receive, SCAN, "Scan", false), + (Tab::Me, USER_CIRCLE, "Me", false), + ]; + ui.horizontal(|ui| { + let w = ui.available_width() / tabs.len() as f32; + for (tab, icon, label, badge) in tabs { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 48.0), Sense::click()); + let active = self.tab == tab; + let color = if active { t.text } else { t.text_mute }; + ui.painter().text( + rect.center() - Vec2::new(0.0, 8.0), + egui::Align2::CENTER_CENTER, + icon, + FontId::new(22.0, fonts::regular()), + color, + ); + ui.painter().text( + rect.center() + Vec2::new(0.0, 14.0), + egui::Align2::CENTER_CENTER, + label, + FontId::new(11.0, fonts::semibold()), + color, + ); + if badge { + ui.painter() + .circle_filled(rect.center() + Vec2::new(12.0, -10.0), 4.0, t.neg); + } + if resp.clicked() { + if tab == Tab::Receive { + // "Scan" routes to receive screen for now. + self.tab = Tab::Receive; + } else { + self.tab = tab; + } + } + } + }); + } + + fn home_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let data = wallet.get_data(); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(48.0); + let spendable = data + .as_ref() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + w::balance_hero(ui, spendable, fiat_line(&data).as_deref(), 56.0); + ui.add_space(20.0); + let (send, receive) = w::send_receive(ui); + if send { + self.send = Some(SendFlow::default()); + } + if receive { + self.tab = Tab::Receive; + } + ui.add_space(24.0); + + // Recent peers strip. + let peers = recent_peers(wallet, 8); + if !peers.is_empty() { + w::kicker(ui, "Recent"); + ui.add_space(12.0); + ScrollArea::horizontal() + .id_salt("goblin_peers") + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (name, hue, npub) in &peers { + ui.vertical(|ui| { + let resp = w::avatar(ui, name, 48.0, *hue); + ui.add_space(6.0); + let short: String = name.chars().take(6).collect(); + ui.label( + RichText::new(short) + .font(FontId::new(12.0, fonts::medium())), + ); + if resp.clicked() { + let mut f = SendFlow::default(); + f.prefill_contact(name.clone(), npub.clone()); + self.send = Some(f); + } + }); + ui.add_space(12.0); + } + }); + }); + ui.add_space(20.0); + } + + // Recent activity. + w::kicker(ui, "Activity"); + ui.add_space(6.0); + let items = activity_items(wallet); + if items.is_empty() { + empty_state( + ui, + "No activity yet", + "Send or receive grin to get started.", + ); + } else { + for item in items.iter().take(6) { + self.activity_item_ui(ui, item, wallet, cb); + } + } + ui.add_space(16.0); + }); + } + + fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + ui.add_space(8.0); + ui.label( + RichText::new("Activity") + .font(FontId::new(28.0, fonts::bold())) + .color(theme::tokens().text), + ); + ui.add_space(12.0); + + // Pending payment requests pinned on top. + if let Some(service) = wallet.nostr_service() { + let requests = service.store.pending_requests(); + if !requests.is_empty() { + w::section_header(ui, "Requests"); + for req in requests { + self.request_row_ui(ui, &req, wallet); + } + ui.add_space(8.0); + } + } + + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let items = activity_items(wallet); + if items.is_empty() { + empty_state(ui, "No activity yet", "Your payments will appear here."); + } else { + for item in &items { + self.activity_item_ui(ui, item, wallet, cb); + } + } + ui.add_space(16.0); + }); + } + + fn activity_item_ui( + &mut self, + ui: &mut egui::Ui, + item: &ActivityItem, + _wallet: &Wallet, + _cb: &dyn PlatformCallbacks, + ) { + let sign = if item.incoming { "+ " } else { "− " }; + let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); + let subtitle = match (&item.note, item.confirmed) { + (Some(note), true) => format!("{} · {}", note, View::format_time(item.time)), + (Some(note), false) => format!("{} · pending", note), + (None, true) => View::format_time(item.time), + (None, false) => "pending".to_string(), + }; + w::activity_row( + ui, + &item.title, + &subtitle, + item.hue, + &amount, + item.incoming, + item.system, + ); + } + + fn request_row_ui( + &mut self, + ui: &mut egui::Ui, + req: &crate::nostr::PaymentRequest, + wallet: &Wallet, + ) { + let t = theme::tokens(); + let (name, hue) = wallet + .nostr_service() + .map(|s| data::contact_title(&s.store, &req.npub)) + .unwrap_or_else(|| (data::short_npub(&req.npub), 0)); + w::card(ui, |ui| { + ui.horizontal(|ui| { + w::avatar(ui, &name, 40.0, hue); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label( + RichText::new(format!("{} requests", name)) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.text), + ); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label( + RichText::new(w::amount_str(req.amount)) + .font(FontId::new(15.0, fonts::mono_semibold())) + .color(t.text), + ); + ui.label( + RichText::new(w::TSU) + .font(FontId::new(13.0, fonts::medium())) + .color(t.text_dim), + ); + }); + }); + }); + if let Some(note) = &req.note { + ui.add_space(6.0); + ui.label( + RichText::new(format!("\u{201C}{}\u{201D}", note)) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if decline_button(ui) { + let mut r = req.clone(); + r.status = crate::nostr::RequestStatus::Declined; + if let Some(s) = wallet.nostr_service() { + s.store.save_request(&r); + } + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if approve_button(ui) { + wallet.task(crate::wallet::types::WalletTask::NostrPayRequest( + req.rumor_id.clone(), + )); + } + }, + ); + }); + }); + ui.add_space(10.0); + } + + fn receive_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + ui.add_space(8.0); + ui.label( + RichText::new("Receive") + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(16.0); + + let handle = wallet + .nostr_service() + .map(|s| { + let identity = s.identity.read(); + identity + .nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))) + }) + .unwrap_or_else(|| "—".to_string()); + let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default(); + + w::card(ui, |ui| { + ui.vertical_centered(|ui| { + // QR of the nostr handle (nostr: URI). + let uri = format!("nostr:{}", npub); + crate::gui::views::QrCodeContent::new(uri, false).ui(ui, cb); + ui.add_space(8.0); + ui.label( + RichText::new(&handle) + .font(FontId::new(18.0, fonts::bold())) + .color(t.text), + ); + ui.label( + RichText::new("Share your handle to get paid") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + }); + }); + + ui.add_space(12.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, &format!("{} Copy", COPY), true).clicked() { + let copy = if npub.is_empty() { + handle.clone() + } else { + npub.clone() + }; + cb.copy_string_to_buffer(copy); + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, "Slatepack", false).clicked() { + // Fall back to manual slatepack receive via legacy flow. + self.tab = Tab::Activity; + } + }, + ); + }); + + ui.add_space(16.0); + ui.label( + RichText::new( + "Your username is public. Payment contents stay encrypted over the network.", + ) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + } + + fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + ui.add_space(8.0); + ui.label( + RichText::new("Settings") + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(16.0); + + // Profile card. + let (handle, npub, connected) = wallet + .nostr_service() + .map(|s| { + let identity = s.identity.read(); + let handle = identity + .nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))); + (handle, s.npub(), s.is_connected()) + }) + .unwrap_or_else(|| ("Anonymous".to_string(), String::new(), false)); + + w::card(ui, |ui| { + ui.horizontal(|ui| { + w::avatar(ui, &handle, 56.0, 6); + ui.add_space(14.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&handle) + .font(FontId::new(17.0, fonts::bold())) + .color(t.text), + ); + let status = if connected { + "Connected over Tor" + } else { + "Connecting…" + }; + ui.label( + RichText::new(status) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + }); + }); + }); + + ui.add_space(16.0); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + settings_group(ui, "Privacy", |ui| { + settings_row(ui, "Tor routing", "All payments routed over Tor"); + settings_row(ui, "Auto-accept", accept_policy_label(wallet)); + }); + ui.add_space(16.0); + settings_group(ui, "Wallet", |ui| { + settings_row(ui, "Display unit", "ツ (grin)"); + settings_row(ui, "Relays", &relay_summary(wallet)); + if !npub.is_empty() { + if settings_row_btn(ui, "Backup nostr key", COPY) { + cb.copy_string_to_buffer(npub.clone()); + } + } + }); + ui.add_space(16.0); + settings_group(ui, "About", |ui| { + settings_row(ui, "Network", "Mimblewimble · no address on chain"); + settings_row(ui, "Version", env!("CARGO_PKG_VERSION")); + }); + ui.add_space(16.0); + }); + } +} + +/// Draw the small Goblin mascot mark. +pub fn widgets_logo(ui: &mut egui::Ui) { + let size = 22.0; + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-64.png")) + .tint(theme::tokens().text) + .fit_to_exact_size(Vec2::splat(size)); + img.paint_at(ui, rect); +} + +fn empty_state(ui: &mut egui::Ui, title: &str, subtitle: &str) { + let t = theme::tokens(); + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(title) + .font(FontId::new(17.0, fonts::semibold())) + .color(t.text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(subtitle) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); +} + +fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { + w::kicker(ui, title); + ui.add_space(8.0); + w::card(ui, |ui| add(ui)); +} + +fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { + let t = theme::tokens(); + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + }); + }); + ui.add_space(10.0); +} + +fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { + let t = theme::tokens(); + let mut clicked = false; + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let resp = ui.label( + RichText::new(icon) + .font(FontId::new(18.0, fonts::regular())) + .color(t.text_dim), + ); + if resp.interact(Sense::click()).clicked() { + clicked = true; + } + }); + }); + ui.add_space(10.0); + clicked +} + +fn approve_button(ui: &mut egui::Ui) -> bool { + w::big_action(ui, "Approve", false).clicked() +} + +fn decline_button(ui: &mut egui::Ui) -> bool { + w::big_action(ui, "Decline", true).clicked() +} + +fn accept_policy_label(wallet: &Wallet) -> &'static str { + use crate::nostr::config::AcceptPolicy; + wallet + .nostr_service() + .map(|s| match s.config.read().accept_from() { + AcceptPolicy::Everyone => "Anyone", + AcceptPolicy::Contacts => "Contacts only", + AcceptPolicy::Ask => "Always ask", + }) + .unwrap_or("Anyone") +} + +fn relay_summary(wallet: &Wallet) -> String { + wallet + .nostr_service() + .map(|s| { + let relays = s.relays(); + match relays.len() { + 0 => "none".to_string(), + 1 => relays[0].replace("wss://", ""), + n => format!("{} relays", n), + } + }) + .unwrap_or_else(|| "—".to_string()) +} + +/// Compute a fiat preview line for the balance, when a rate is available. +fn fiat_line(data: &Option) -> Option { + let _ = data; + // Fiat rate provider is wired in P7; hide the line until a rate is available. + crate::http::grin_usd_rate().map(|rate| { + let spendable = data + .as_ref() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let grin = spendable as f64 / 1_000_000_000.0; + format!("≈ ${:.2} · 1ツ = ${:.4}", grin * rate, rate) + }) +} + +/// Convert a bech32 npub to hex for short display fallbacks. +fn hex_of(npub: &str) -> String { + use nostr_sdk::{FromBech32, PublicKey}; + PublicKey::from_bech32(npub) + .map(|pk| pk.to_hex()) + .unwrap_or_else(|_| npub.to_string()) +} diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs new file mode 100644 index 00000000..c40484d0 --- /dev/null +++ b/src/gui/views/goblin/send.rs @@ -0,0 +1,573 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The Goblin send flow: pick recipient → enter amount → review → success. + +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::platform::PlatformCallbacks; +use crate::gui::theme::{self, fonts}; +use crate::gui::views::View; +use crate::nostr::nip05; +use crate::wallet::Wallet; +use crate::wallet::types::WalletTask; + +use super::data::{display_name, recent_peers, short_npub}; +use super::widgets::{self as w, HoldToSend}; + +/// Stage of the send flow. +#[derive(PartialEq, Eq)] +enum Stage { + Recipient, + Amount, + Review, + Sending, + Success, +} + +/// A resolved recipient. +#[derive(Clone)] +struct Recipient { + name: String, + npub: String, + hue: usize, +} + +/// The send flow state. +pub struct SendFlow { + stage: Stage, + search: String, + recipient: Option, + amount: String, + note: String, + hold: HoldToSend, + error: Option, + /// Resolution in flight (bech32/nip05). + resolving: bool, +} + +impl Default for SendFlow { + fn default() -> Self { + Self { + stage: Stage::Recipient, + search: String::new(), + recipient: None, + amount: String::new(), + note: String::new(), + hold: HoldToSend::default(), + error: None, + resolving: false, + } + } +} + +impl SendFlow { + /// Pre-fill a contact and skip to amount entry. + pub fn prefill_contact(&mut self, name: String, npub: String) { + let hue = usize::from_str_radix(&npub[..2.min(npub.len())], 16).unwrap_or(0) % 7; + self.recipient = Some(Recipient { name, npub, hue }); + self.stage = Stage::Amount; + } + + /// Render the flow. Returns true when the flow is finished (close it). + pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) -> bool { + let t = theme::tokens(); + let mut done = false; + egui::CentralPanel::default() + .frame(egui::Frame { + fill: if self.stage == Stage::Success { + t.accent + } else { + t.bg + }, + inner_margin: egui::Margin { + left: (View::far_left_inset_margin(ui) + 20.0) as i8, + right: (View::get_right_inset() + 20.0) as i8, + top: (View::get_top_inset() + 12.0) as i8, + bottom: (View::get_bottom_inset() + 12.0) as i8, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + w::centered_column( + ui, + crate::gui::views::Content::SIDE_PANEL_WIDTH * 1.2, + |ui| match self.stage { + Stage::Recipient => done = self.recipient_ui(ui, wallet, cb), + Stage::Amount => done = self.amount_ui(ui, wallet), + Stage::Review => done = self.review_ui(ui, wallet), + Stage::Sending => self.sending_ui(ui, wallet), + Stage::Success => done = self.success_ui(ui), + }, + ); + }); + done + } + + fn back_header(&self, ui: &mut egui::Ui, title: &str) -> bool { + let t = theme::tokens(); + let mut back = false; + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + t.text, + ); + back = resp.clicked(); + ui.add_space(12.0); + ui.label( + RichText::new(title) + .font(FontId::new(18.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(12.0); + back + } + + fn recipient_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) -> bool { + let t = theme::tokens(); + if self.back_header(ui, "Send to") { + return true; + } + + // Search field. + let mut search = self.search.clone(); + ui.horizontal(|ui| { + ui.label( + RichText::new(MAGNIFYING_GLASS) + .font(FontId::new(18.0, fonts::regular())) + .color(t.text_mute), + ); + ui.add_space(8.0); + let edit = egui::TextEdit::singleline(&mut search) + .hint_text("@handle, npub, or name") + .desired_width(ui.available_width()) + .frame(false); + ui.add(edit); + }); + if search != self.search { + self.search = search; + self.error = None; + } + ui.add_space(6.0); + View::horizontal_line(ui, t.line); + ui.add_space(12.0); + + // Resolve button when input looks like a handle/npub. + if !self.search.trim().is_empty() { + if w::big_action(ui, "Find recipient", false).clicked() { + self.resolve_search(wallet); + } + if let Some(err) = &self.error { + ui.add_space(8.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(16.0); + } + + // Suggested contacts / recent peers. + ui.label( + RichText::new(format!("{} Suggested", USERS)) + .font(fonts::kicker()) + .color(t.text_mute), + ); + ui.add_space(8.0); + let peers = recent_peers(wallet, 20); + let _ = cb; + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + if peers.is_empty() { + ui.add_space(20.0); + ui.label( + RichText::new("No contacts yet. Find someone by their @handle.") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + } + for (name, hue, npub) in peers { + let resp = + w::activity_row(ui, &name, &short_npub(&npub), hue, "", false, false); + if resp.clicked() { + self.recipient = Some(Recipient { name, npub, hue }); + self.stage = Stage::Amount; + } + } + }); + false + } + + fn resolve_search(&mut self, wallet: &Wallet) { + let input = self.search.trim().to_string(); + self.resolving = true; + // Try bech32 npub directly. + use nostr_sdk::{FromBech32, PublicKey}; + if let Ok(pk) = PublicKey::from_bech32(&input) { + self.set_recipient_from_hex(wallet, &pk.to_hex(), None); + self.resolving = false; + return; + } + if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) { + self.set_recipient_from_hex(wallet, &input.to_lowercase(), None); + self.resolving = false; + return; + } + // Try NIP-05 resolution (user@domain or @user) over Tor. + if let Some((name, domain)) = nip05::split_identifier(&input) { + match resolve_nip05_blocking(&name, &domain) { + Some(res) => { + let nip05_id = format!("{}@{}", name, domain); + self.set_recipient_from_hex(wallet, &res.pubkey.to_hex(), Some(nip05_id)); + } + None => self.error = Some("Couldn't find that handle".to_string()), + } + } else { + self.error = Some("Enter an @handle, npub, or name".to_string()); + } + self.resolving = false; + } + + fn set_recipient_from_hex(&mut self, wallet: &Wallet, hex: &str, nip05: Option) { + let (name, hue) = wallet + .nostr_service() + .map(|s| { + if let Some(c) = s.store.contact(hex) { + (display_name(&c), c.hue as usize) + } else { + let hue = usize::from_str_radix(&hex[..2], 16).unwrap_or(0) % 7; + ( + nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| short_npub(hex)), + hue, + ) + } + }) + .unwrap_or_else(|| (short_npub(hex), 0)); + self.recipient = Some(Recipient { + name, + npub: hex.to_string(), + hue, + }); + self.stage = Stage::Amount; + } + + fn amount_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + let t = theme::tokens(); + if self.back_header(ui, "Amount") { + self.stage = Stage::Recipient; + return false; + } + let recipient = self.recipient.clone().unwrap(); + + // Recipient chip. + ui.vertical_centered(|ui| { + ui.horizontal(|ui| { + w::avatar(ui, &recipient.name, 28.0, recipient.hue); + ui.add_space(8.0); + ui.label( + RichText::new(format!("To {}", recipient.name)) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.text), + ); + }); + }); + ui.add_space(20.0); + + // Big amount display. + let display = if self.amount.is_empty() { + "0".to_string() + } else { + self.amount.clone() + }; + ui.vertical_centered(|ui| { + w::amount_text(ui, &display, 64.0); + if let Some(rate) = crate::http::grin_usd_rate() { + if let Ok(grin) = display.parse::() { + ui.add_space(6.0); + ui.label( + RichText::new(format!("≈ ${:.2}", grin * rate)) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + } + } + }); + ui.add_space(16.0); + + // Quick chips. + ui.horizontal(|ui| { + ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0); + for v in ["1", "10", "100", "Max"] { + if w::chip(ui, v, false).clicked() { + if v == "Max" { + let max = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + self.amount = w::amount_str(max); + } else { + self.amount = v.to_string(); + } + } + ui.add_space(8.0); + } + }); + ui.add_space(16.0); + + // Note field. + w::card(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Note") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(8.0); + let edit = egui::TextEdit::singleline(&mut self.note) + .hint_text("Add a note…") + .desired_width(ui.available_width()) + .frame(false); + ui.add(edit); + }); + }); + ui.add_space(12.0); + + // Numpad (mobile) — desktop also accepts keyboard via egui events. + if !View::is_desktop() { + w::numpad(ui, &mut self.amount); + } else { + ui.input(|i| { + for ev in &i.events { + if let egui::Event::Text(txt) = ev { + for ch in txt.chars() { + if ch.is_ascii_digit() { + w::apply_key(&mut self.amount, &ch.to_string()); + } else if ch == '.' { + w::apply_key(&mut self.amount, "."); + } + } + } + if let egui::Event::Key { + key: egui::Key::Backspace, + pressed: true, + .. + } = ev + { + w::apply_key(&mut self.amount, "<"); + } + } + }); + } + ui.add_space(8.0); + + let valid = amount_from_hr_string(&self.amount) + .map(|a| a > 0) + .unwrap_or(false); + ui.add_enabled_ui(valid, |ui| { + if w::big_action(ui, "Review", false).clicked() { + self.stage = Stage::Review; + } + }); + false + } + + fn review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + let t = theme::tokens(); + if self.back_header(ui, "Review") { + self.stage = Stage::Amount; + return false; + } + let recipient = self.recipient.clone().unwrap(); + let amount = self.amount.clone(); + + w::card(ui, |ui| { + ui.vertical_centered(|ui| { + ui.horizontal(|ui| { + w::avatar(ui, &recipient.name, 36.0, recipient.hue); + ui.add_space(8.0); + ui.label( + RichText::new(format!("You're sending {}", recipient.name)) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.add_space(8.0); + w::amount_text(ui, &amount, 48.0); + }); + }); + ui.add_space(16.0); + + w::info_row(ui, "To", &recipient.name); + if !self.note.trim().is_empty() { + w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim())); + } + w::info_row(ui, "Privacy", "Mimblewimble"); + 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; + } + 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), + ); + }); + false + } + + fn dispatch(&mut self, wallet: &Wallet) { + if let (Some(recipient), Ok(amount)) = + (&self.recipient, amount_from_hr_string(&self.amount)) + { + let note = if self.note.trim().is_empty() { + None + } else { + Some(self.note.trim().to_string()) + }; + wallet.task(WalletTask::NostrSend(amount, recipient.npub.clone(), note)); + } + } + + fn sending_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + ui.add_space(80.0); + ui.vertical_centered(|ui| { + View::big_loading_spinner(ui); + ui.add_space(16.0); + ui.label( + RichText::new("Sending…") + .font(FontId::new(18.0, fonts::semibold())) + .color(t.text), + ); + }); + // Move to success once the wallet is no longer creating the send. + if !wallet.send_creating() { + self.stage = Stage::Success; + } + ui.ctx().request_repaint(); + } + + fn success_ui(&mut self, ui: &mut egui::Ui) -> bool { + let t = theme::tokens(); + let recipient = self.recipient.clone().unwrap(); + ui.add_space(80.0); + ui.vertical_centered(|ui| { + // Mascot in an ink circle. + let (rect, _) = ui.allocate_exact_size(Vec2::splat(120.0), Sense::hover()); + ui.painter() + .circle_filled(rect.center(), 60.0, t.accent_ink); + let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-128.png")) + .tint(t.accent) + .fit_to_exact_size(Vec2::splat(72.0)); + img.paint_at( + ui, + egui::Rect::from_center_size(rect.center(), Vec2::splat(72.0)), + ); + ui.add_space(24.0); + ui.label( + RichText::new("Sent") + .font(FontId::new(34.0, fonts::bold())) + .color(t.accent_ink), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let total_w = ui.available_width(); + ui.add_space(total_w / 2.0 - 60.0); + ui.label( + RichText::new(&self.amount) + .font(FontId::new(40.0, fonts::mono_semibold())) + .color(t.accent_ink), + ); + ui.label( + RichText::new(w::TSU) + .font(FontId::new(20.0, fonts::medium())) + .color(t.accent_ink), + ); + }); + ui.add_space(8.0); + ui.label( + RichText::new(format!("to {} · just now", recipient.name)) + .font(FontId::new(15.0, fonts::regular())) + .color(t.accent_ink.gamma_multiply(0.7)), + ); + }); + + let mut done = false; + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.add_space(20.0); + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 56.0), Sense::click()); + ui.painter().rect( + rect, + eframe::epaint::CornerRadius::same(14), + t.accent_ink, + eframe::epaint::Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Done", + FontId::new(17.0, fonts::semibold()), + t.accent, + ); + if resp.clicked() { + done = true; + } + }); + done + } +} + +/// Resolve a NIP-05 identifier on a short-lived runtime (blocking the UI +/// briefly is acceptable for an explicit "find recipient" action). +fn resolve_nip05_blocking(name: &str, domain: &str) -> Option { + let name = name.to_string(); + let domain = domain.to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok()?; + rt.block_on(nip05::resolve(&name, &domain)) + }) + .join() + .ok() + .flatten() +} diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs new file mode 100644 index 00000000..e9e1932b --- /dev/null +++ b/src/gui/views/goblin/widgets.rs @@ -0,0 +1,488 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Reusable Goblin design widgets: avatars, amounts, buttons, rows, chips. + +use eframe::epaint::{CornerRadius, FontId, Stroke}; +use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, Vec2}; + +use crate::gui::theme::{self, fonts}; + +/// Currency mark for grin amounts. +pub const TSU: &str = "ツ"; + +/// Format atomic grin units to a trimmed human string (no unit). +pub fn amount_str(atomic: u64) -> String { + grin_core::core::amount_to_hr_string(atomic, true) +} + +/// Draw a colored avatar puck with the contact initial. +pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let bg = theme::avatar_color(hue); + ui.painter().circle_filled(rect.center(), size / 2.0, bg); + let initial = name + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + initial, + FontId::new(size * 0.42, fonts::bold()), + theme::ink_for(bg), + ); + resp +} + +/// Draw a balance/amount: big mono number + smaller ツ mark, tight. +pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { + let t = theme::tokens(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label( + RichText::new(value) + .font(FontId::new(size, fonts::mono_semibold())) + .color(t.text), + ); + ui.add_space(1.0); + ui.label( + RichText::new(TSU) + .font(FontId::new(size * 0.4, fonts::medium())) + .color(t.text_dim), + ); + }); +} + +/// An uppercase letterspaced kicker label. +pub fn kicker(ui: &mut Ui, text: &str) { + let t = theme::tokens(); + ui.label( + RichText::new(text.to_uppercase()) + .font(fonts::kicker()) + .color(t.text_mute), + ); +} + +/// Big primary/secondary action button (56px, radius 14). +pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 56.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + let (fill, ink, stroke) = if secondary { + (Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line)) + } else { + (t.accent, t.accent_ink, Stroke::NONE) + }; + let visual_fill = if resp.hovered() && !secondary { + t.accent_dark + } else { + fill + }; + ui.painter().rect( + rect, + CornerRadius::same(14), + visual_fill, + stroke, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + ink, + ); + resp +} + +/// A pill/chip; returns the click response. `active` paints it inverted. +pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { + let t = theme::tokens(); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + FontId::new(13.0, fonts::semibold()), + if active { t.bg } else { t.text }, + ); + let pad = Vec2::new(14.0, 8.0); + let size = galley.size() + pad * 2.0; + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let fill = if active { t.text } else { t.surface2 }; + ui.painter().rect( + rect, + CornerRadius::same(255), + fill, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().galley( + rect.center() - galley.size() / 2.0, + galley, + if active { t.bg } else { t.text }, + ); + resp +} + +/// A balance hero block: kicker, big number + ツ, optional fiat line. +pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) { + let t = theme::tokens(); + kicker(ui, "Balance"); + ui.add_space(6.0); + amount_text(ui, &amount_str(atomic), size); + if let Some(fiat) = fiat { + ui.add_space(4.0); + ui.label( + RichText::new(fiat) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } +} + +/// An activity row: avatar, title, subtitle, signed amount. +/// Returns the row click response. +pub fn activity_row( + ui: &mut Ui, + title: &str, + subtitle: &str, + hue: usize, + amount: &str, + incoming: bool, + system: bool, +) -> Response { + let t = theme::tokens(); + let row_h = 60.0; + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click()); + let mut content = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect.shrink2(Vec2::new(0.0, 8.0))) + .layout(Layout::left_to_right(Align::Center)), + ); + content.horizontal(|ui| { + if system { + let (r, _) = ui.allocate_exact_size(Vec2::splat(40.0), Sense::hover()); + ui.painter().rect( + r, + CornerRadius::same(10), + t.surface2, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + crate::gui::icons::CUBE, + FontId::new(20.0, fonts::regular()), + t.text, + ); + } else { + avatar(ui, title, 40.0, hue); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(2.0); + ui.label( + RichText::new(title) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.text), + ); + ui.label( + RichText::new(subtitle) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(amount) + .font(FontId::new(15.0, fonts::mono_semibold())) + .color(if incoming { t.pos } else { t.text }), + ); + }); + }); + // Divider. + let line_y = rect.bottom(); + ui.painter() + .hline(rect.left()..=rect.right(), line_y, Stroke::new(1.0, t.line)); + resp +} + +/// Section header used above grouped lists. +pub fn section_header(ui: &mut Ui, text: &str) { + ui.add_space(8.0); + kicker(ui, text); + ui.add_space(6.0); +} + +/// Draw a rounded surface card and run a closure inside it. +pub fn card(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + let t = theme::tokens(); + egui::Frame::new() + .fill(t.surface) + .stroke(Stroke::new(1.0, t.line)) + .corner_radius(CornerRadius::same(18)) + .inner_margin(16.0) + .show(ui, add_contents) + .inner +} + +/// A bordered rect helper for non-interactive value rows. +pub fn info_row(ui: &mut Ui, label: &str, value: &str) { + let t = theme::tokens(); + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(value) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.text), + ); + }); + }); + ui.add_space(8.0); + ui.painter().hline( + ui.min_rect().left()..=ui.min_rect().right(), + ui.cursor().top(), + Stroke::new(1.0, t.line), + ); + ui.add_space(8.0); +} + +/// Draw a centered Send / Receive split. Returns (send, receive) clicks. +pub fn send_receive(ui: &mut Ui) -> (bool, bool) { + let t = theme::tokens(); + let mut send = false; + let mut receive = false; + let h = 60.0; + ui.horizontal(|ui| { + let w = (ui.available_width() - 10.0) / 2.0; + let (rs, resp_s) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + ui.painter().rect( + rs, + CornerRadius::same(14), + if resp_s.hovered() { + t.accent_dark + } else { + t.accent + }, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rs.center(), + egui::Align2::CENTER_CENTER, + format!("{} Send", crate::gui::icons::ARROW_UP), + FontId::new(16.0, fonts::semibold()), + t.accent_ink, + ); + send = resp_s.clicked(); + ui.add_space(10.0); + let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + ui.painter().rect( + rr, + CornerRadius::same(14), + if resp_r.hovered() { + t.hover + } else { + t.surface2 + }, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + rr.center(), + egui::Align2::CENTER_CENTER, + format!("{} Receive", crate::gui::icons::ARROW_DOWN), + FontId::new(16.0, fonts::semibold()), + t.text, + ); + receive = resp_r.clicked(); + }); + (send, receive) +} + +/// A simple numeric keypad. Mutates `amount` string. Returns true if changed. +pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool { + let t = theme::tokens(); + let mut changed = false; + let keys = [ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], + [".", "0", "<"], + ]; + let key_h = 56.0; + for row in keys.iter() { + ui.horizontal(|ui| { + let w = (ui.available_width() - 2.0 * 8.0) / 3.0; + for &k in row.iter() { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, key_h), Sense::click()); + let label = if k == "<" { + crate::gui::icons::BACKSPACE.to_string() + } else { + k.to_string() + }; + let col = if resp.hovered() { t.text } else { t.text }; + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(28.0, fonts::medium()), + col, + ); + if resp.clicked() { + apply_key(amount, k); + changed = true; + } + ui.add_space(8.0); + } + }); + ui.add_space(4.0); + } + changed +} + +/// Apply a numpad key to the amount string with validation. +pub fn apply_key(amount: &mut String, key: &str) { + match key { + "<" => { + amount.pop(); + } + "." => { + if !amount.contains('.') { + if amount.is_empty() { + amount.push('0'); + } + amount.push('.'); + } + } + d => { + // Limit to 9 decimals (grin precision). + if let Some(dot) = amount.find('.') { + if amount.len() - dot - 1 >= 9 { + return; + } + } + // Avoid leading zeros like "00". + if amount == "0" { + amount.clear(); + } + amount.push_str(d); + } + } +} + +/// Paint a full-rect background fill on the current panel. +pub fn fill_bg(ui: &Ui, color: Color32) { + let rect = ui.ctx().screen_rect(); + ui.painter().rect_filled(rect, CornerRadius::ZERO, color); +} + +/// Center a fixed-width column for narrow content on wide screens. +pub fn centered_column(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let avail = ui.available_width(); + let w = width.min(avail); + let margin = ((avail - w) / 2.0).max(0.0); + let mut result = None; + ui.horizontal(|ui| { + ui.add_space(margin); + ui.vertical(|ui| { + ui.set_width(w); + result = Some(add(ui)); + }); + }); + result.unwrap() +} + +/// Hold-to-send button: fills over `hold_secs`; returns true once on completion. +pub struct HoldToSend { + progress: f32, +} + +impl Default for HoldToSend { + fn default() -> Self { + Self { progress: 0.0 } + } +} + +impl HoldToSend { + pub fn ui(&mut self, ui: &mut Ui, label: &str) -> bool { + let t = theme::tokens(); + let (rect, resp) = ui.allocate_exact_size( + Vec2::new(ui.available_width(), 56.0), + Sense::click_and_drag(), + ); + // Background. + ui.painter().rect( + rect, + CornerRadius::same(14), + t.surface2, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + let held = resp.is_pointer_button_down_on() || resp.dragged(); + let dt = ui.input(|i| i.stable_dt).min(0.1); + if held { + self.progress = (self.progress + dt / 0.7).min(1.0); + ui.ctx().request_repaint(); + } else { + self.progress = (self.progress - dt / 0.3).max(0.0); + if self.progress > 0.0 { + ui.ctx().request_repaint(); + } + } + // Progress fill. + if self.progress > 0.0 { + let mut fill_rect = rect; + fill_rect.set_width(rect.width() * self.progress); + ui.painter().rect( + fill_rect, + CornerRadius::same(14), + t.accent, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + if self.progress > 0.5 { + t.accent_ink + } else { + t.text + }, + ); + if self.progress >= 1.0 { + self.progress = 0.0; + return true; + } + false + } +} + +/// Shorten a long key/address for display (8…6). +pub fn short_key(key: &str) -> String { + if key.len() <= 16 { + return key.to_string(); + } + format!("{}…{}", &key[..8], &key[key.len() - 6..]) +} diff --git a/src/gui/views/mod.rs b/src/gui/views/mod.rs index b10f1d8d..099cb174 100644 --- a/src/gui/views/mod.rs +++ b/src/gui/views/mod.rs @@ -26,6 +26,7 @@ pub use modal::*; mod content; pub use content::*; +pub mod goblin; pub mod network; pub mod settings; pub mod wallets; diff --git a/src/gui/views/wallets/mod.rs b/src/gui/views/wallets/mod.rs index f88e145b..00a45c91 100644 --- a/src/gui/views/wallets/mod.rs +++ b/src/gui/views/wallets/mod.rs @@ -18,5 +18,5 @@ pub mod modals; mod content; pub use content::*; -mod wallet; +pub mod wallet; use wallet::*; diff --git a/src/gui/views/wallets/wallet/content.rs b/src/gui/views/wallets/wallet/content.rs index 147643ef..9e0598de 100644 --- a/src/gui/views/wallets/wallet/content.rs +++ b/src/gui/views/wallets/wallet/content.rs @@ -52,6 +52,9 @@ pub struct WalletContent { invoice_content: Option, /// Send request creation [`Modal`] content. send_content: Option, + + /// Goblin Cash App-style surface (primary UI). + goblin: crate::gui::views::goblin::GoblinWalletView, } /// Identifier for invoice creation [`Modal`]. @@ -83,6 +86,41 @@ impl WalletContentContainer for WalletContent { } fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Goblin surface is the primary UI. Show a sync screen until data is + // ready, then hand the whole surface to the Cash App-style view. + let block_nav_goblin = self.block_navigation_on_sync(wallet); + if block_nav_goblin || wallet.get_data().is_none() { + egui::CentralPanel::default() + .frame(egui::Frame { + fill: Colors::fill(), + ..Default::default() + }) + .show_inside(ui, |ui| { + sync_ui(ui, wallet); + }); + self.handle_task_result(wallet); + return; + } + egui::CentralPanel::default() + .frame(egui::Frame { + fill: Colors::fill(), + ..Default::default() + }) + .show_inside(ui, |ui| { + self.goblin.ui(ui, wallet, cb); + self.handle_task_result(wallet); + }); + } +} + +impl WalletContent { + #[allow(dead_code)] + fn legacy_container_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { let dual_panel = Content::is_dual_panel_mode(ui.ctx()); let show_wallets_dual = AppConfig::show_wallets_at_dual_panel(); @@ -293,6 +331,7 @@ impl Default for WalletContent { transport_content: WalletTransportContent::default(), invoice_content: None, send_content: None, + goblin: crate::gui::views::goblin::GoblinWalletView::default(), } } } @@ -317,16 +356,24 @@ impl WalletContent { /// Check if it's possible to go back at navigation stack. pub fn can_back(&self) -> bool { - self.account_content.can_back() || self.transport_content.can_back() + self.goblin.overlay_active() + || self.account_content.can_back() + || self.transport_content.can_back() } - /// Navigate back on navigation stack. - pub fn back(&mut self, cb: &dyn PlatformCallbacks) { + /// Navigate back on navigation stack. Returns true if not consumed. + pub fn back(&mut self, cb: &dyn PlatformCallbacks) -> bool { + if self.goblin.overlay_active() { + return self.goblin.on_back(); + } if self.account_content.can_back() { self.account_content.back(cb); + return false; } else if self.transport_content.can_back() { self.transport_content.back(); + return false; } + self.goblin.on_back() } /// Check when to block tabs navigation on sync progress. diff --git a/src/http/mod.rs b/src/http/mod.rs index 78270406..25891472 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -17,3 +17,6 @@ pub use client::*; mod release; pub use release::*; + +mod price; +pub use price::grin_usd_rate; diff --git a/src/http/price.rs b/src/http/price.rs new file mode 100644 index 00000000..469b47ee --- /dev/null +++ b/src/http/price.rs @@ -0,0 +1,86 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! GRIN/USD price preview, fetched over Tor and cached. + +use lazy_static::lazy_static; +use parking_lot::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::AppConfig; +use crate::tor::Tor; + +/// Cache refresh interval (seconds). +const REFRESH_SECS: i64 = 300; + +lazy_static! { + /// Cached (rate, fetched_at) and an in-flight flag. + static ref RATE: RwLock> = RwLock::new(None); + static ref FETCHING: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Get the cached GRIN/USD rate if fresh, triggering a refresh otherwise. +/// Returns `None` until the first successful fetch or when fiat is disabled. +pub fn grin_usd_rate() -> Option { + if !AppConfig::fiat_preview() { + return None; + } + let cached = { RATE.read().clone() }; + let needs_refresh = match cached { + Some((_, ts)) => now() - ts > REFRESH_SECS, + None => true, + }; + if needs_refresh { + trigger_refresh(); + } + cached.map(|(rate, _)| rate) +} + +/// Spawn a background refresh over Tor (deduplicated). +fn trigger_refresh() { + use std::sync::atomic::Ordering; + if FETCHING.swap(true, Ordering::SeqCst) { + return; + } + std::thread::spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + if let Some(rate) = fetch_rate().await { + let mut w = RATE.write(); + *w = Some((rate, now())); + } + }); + FETCHING.store(false, Ordering::SeqCst); + }); +} + +/// Fetch the GRIN/USD rate from CoinGecko over Tor. +async fn fetch_rate() -> Option { + let url = + "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd".to_string(); + let body = Tor::http_request("GET", url, None, vec![]).await?; + let doc: serde_json::Value = serde_json::from_str(&body).ok()?; + doc.get("grin")?.get("usd")?.as_f64() +} diff --git a/src/lib.rs b/src/lib.rs index 4f41d40d..423ad220 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -271,17 +271,16 @@ pub fn setup_fonts(ctx: &Context) { use egui::FontId; use egui::TextStyle; + // NOTE: text_styles must only reference Proportional/Monospace families. + // set_fonts() applies on the next pass while set_style() is immediate; a + // default text style referencing a custom Name family would panic on the + // first frame before the fonts swap in. Goblin weights are applied at the + // widget call sites via RichText::font(), which render after the swap. let mut style = (*ctx.style()).clone(); style.text_styles = [ - ( - TextStyle::Heading, - FontId::new(19.0, egui::FontFamily::Name("geist-semibold".into())), - ), + (TextStyle::Heading, FontId::new(19.0, Proportional)), (TextStyle::Body, FontId::new(16.0, Proportional)), - ( - TextStyle::Button, - FontId::new(17.0, egui::FontFamily::Name("geist-medium".into())), - ), + (TextStyle::Button, FontId::new(17.0, Proportional)), (TextStyle::Small, FontId::new(15.0, Proportional)), ( TextStyle::Monospace, diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index a02469e6..8344089e 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -19,8 +19,8 @@ mod types; pub use types::*; -mod config; -pub use config::NostrConfig; +pub mod config; +pub use config::{AcceptPolicy, NostrConfig}; pub mod relays; diff --git a/src/settings/config.rs b/src/settings/config.rs index bc9caf17..cae99020 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -75,6 +75,8 @@ pub struct AppConfig { density: Option, /// Identifier of the last opened wallet to boot into. last_wallet_id: Option, + /// Show fiat (USD) preview alongside amounts. + fiat_preview: Option, /// Flag to use proxy for network requests. use_proxy: Option, @@ -109,6 +111,7 @@ impl Default for AppConfig { theme: None, density: None, last_wallet_id: None, + fiat_preview: None, use_proxy: None, use_socks_proxy: None, http_proxy_url: None, @@ -360,6 +363,20 @@ impl AppConfig { w_config.save(); } + /// Check if fiat (USD) preview is enabled (default on). + pub fn fiat_preview() -> bool { + let r_config = Settings::app_config_to_read(); + r_config.fiat_preview.unwrap_or(true) + } + + /// Toggle fiat preview. + pub fn toggle_fiat_preview() { + let enabled = Self::fiat_preview(); + let mut w_config = Settings::app_config_to_update(); + w_config.fiat_preview = Some(!enabled); + w_config.save(); + } + /// Get identifier of the last opened wallet. pub fn last_wallet_id() -> Option { let r_config = Settings::app_config_to_read();