Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,440 changes: 22 additions & 1,418 deletions bitvault-ui/src/app.rs

Large diffs are not rendered by default.

107 changes: 107 additions & 0 deletions bitvault-ui/src/app/assets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use eframe::egui;
use std::path::PathBuf;
use std::sync::OnceLock;

// Base paths to try for asset loading
const BASE_PATHS: [&str; 3] = ["bitvault-ui", ".", ".."];

// Find the correct base path once
fn get_base_path() -> &'static PathBuf {
static BASE_PATH: OnceLock<PathBuf> = OnceLock::new();

BASE_PATH.get_or_init(|| {
for base in BASE_PATHS {
let path = PathBuf::from(base);
if path.exists() {
return path;
}
}
// Default to current directory if nothing found
PathBuf::from(".")
})
}

// Load a font file
pub fn load_font(font_name: &str) -> Option<Vec<u8>> {
let base = get_base_path();
let font_path = base.join("assets").join(font_name);

std::fs::read(&font_path).ok()
}

// Load an image file
pub fn load_image(path: &str) -> Option<Vec<u8>> {
let base = get_base_path();
let img_path = base.join(path);

std::fs::read(&img_path).ok()
}

// SVG loading function that works with the existing dependencies
pub fn load_svg_as_texture(
ctx: &egui::Context,
name: &str,
path: &str,
) -> Option<egui::TextureHandle> {
let base = get_base_path();
let svg_path = base.join(path);

log::debug!("Loading SVG from: {:?}", svg_path);

// First read the SVG file
let svg_data = std::fs::read_to_string(&svg_path).ok()?;

// Parse SVG with usvg
let opt = usvg::Options {
..Default::default()
};

let tree = usvg::Tree::from_str(&svg_data, &opt).ok()?;

// Get the size and create a pixmap
let size = tree.size();

// Apply a scale factor to increase resolution (2.0 = double resolution)
let scale_factor = 2.0;
let scaled_width = (size.width() * scale_factor) as u32;
let scaled_height = (size.height() * scale_factor) as u32;

let pixmap_size = tiny_skia::IntSize::from_wh(scaled_width, scaled_height)?;

// Create a pixmap (tiny-skia's bitmap for rendering)
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height())?;

// Render the SVG tree to the pixmap with the scale transform
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale_factor, scale_factor),
&mut pixmap.as_mut(),
);

// Convert to egui texture
let image_size = [pixmap_size.width() as _, pixmap_size.height() as _];
let image_data = pixmap.data();

// Create the color image and texture
let color_image = egui::ColorImage::from_rgba_unmultiplied(image_size, image_data);

Some(ctx.load_texture(name, color_image, Default::default()))
}

// Get a texture handle for an image
pub fn get_image_texture(
ctx: &egui::Context,
name: &str,
path: &str,
) -> Option<egui::TextureHandle> {
load_image(path).and_then(|image_data| {
image::load_from_memory(&image_data).ok().map(|image| {
let size = [image.width() as _, image.height() as _];
let image_buffer = image.to_rgba8();
let pixels = image_buffer.as_flat_samples();

let color_image = egui::ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
ctx.load_texture(name, color_image, Default::default())
})
})
}
6 changes: 6 additions & 0 deletions bitvault-ui/src/app/screens.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Re-export screen modules
pub mod home;
pub mod lock;
pub mod onboarding;
pub mod seed;
pub mod wallet;
138 changes: 138 additions & 0 deletions bitvault-ui/src/app/screens/home.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use eframe::egui::{self, Color32, RichText, Ui};

use crate::app::assets;
use crate::app::state::{SharedAppState, View, WalletState};
use crate::app::BitVaultApp;

// Main home screen
pub fn render(app: &BitVaultApp, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(50.0);
ui.heading("Welcome to BitVault");
ui.add_space(20.0);

ui.label("Your secure Bitcoin wallet");
ui.add_space(30.0);

if ui.button("Create New Wallet").clicked() {
if let Ok(mut state) = app.state.write() {
state.wallet_state = WalletState::Creating;
state.current_view = View::Disclaimer;
}
}

ui.add_space(10.0);

if ui.button("Restore Existing Wallet").clicked() {
if let Ok(mut state) = app.state.write() {
state.wallet_state = WalletState::Restoring;
state.current_view = View::Disclaimer;
}
}

let back_button_response = ui.add(egui::Button::new("Go Back"));
if back_button_response.clicked() {
if let Ok(mut state) = app.state.write() {
state.current_view = View::Home;
}
}
crate::icons::draw_caret_left(ui, back_button_response.rect, Color32::WHITE);
});
}

// Disclaimer screen
pub fn render_disclaimer(app: &BitVaultApp, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.heading("Important Disclaimer");
ui.add_space(20.0);

ui.label(RichText::new("Please read carefully before proceeding:").strong());
ui.add_space(10.0);

let disclaimer_text = "
1. BitVault is a self-custody wallet. You are solely responsible for your funds.

2. Your recovery phrase (seed) is the ONLY way to recover your wallet if you lose access.

3. Never share your recovery phrase or PIN with anyone.

4. Always back up your recovery phrase in a secure location.

5. If you lose your recovery phrase, you will permanently lose access to your funds.

6. BitVault cannot recover your wallet or funds if you lose your recovery phrase.
";

ui.label(disclaimer_text);
ui.add_space(20.0);

if ui.button("I Understand and Accept").clicked() {
if let Ok(mut state) = app.state.write() {
state.current_view = View::PinChoice;
}
}

let back_button_response = ui.add(egui::Button::new("Go Back"));
if back_button_response.clicked() {
if let Ok(mut state) = app.state.write() {
state.wallet_state = WalletState::New;
state.current_view = View::Home;
}
}
crate::icons::draw_caret_left(ui, back_button_response.rect, Color32::WHITE);
});
}

// Splash screen
pub fn render_splash_screen(ui: &mut Ui, _state: &SharedAppState) {
// Set the background to black
let screen_rect = ui.max_rect();
ui.painter().rect_filled(screen_rect, 0.0, Color32::BLACK);

// Track how many times this method is called
static RENDER_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let count = RENDER_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
log::trace!("Render splash screen called {} times", count);

// Center the logo
ui.vertical_centered(|ui| {
// Use a static texture handle to avoid reloading on every frame
static TEXTURE_ID: std::sync::OnceLock<Option<egui::TextureHandle>> =
std::sync::OnceLock::new();

let texture_id = TEXTURE_ID.get_or_init(|| {
log::debug!("Loading splash logo - this should only happen once");
assets::get_image_texture(ui.ctx(), "splash_logo", "public/splash_logo.png")
});

match texture_id {
Some(texture) => {
// Get texture size and available space
let available_size = ui.available_size();
let texture_size = texture.size_vec2();

// Calculate appropriate scale - use a smaller maximum to prevent oversizing
// Use a target width of 50-60% of screen width, but never larger than original
let target_width_ratio = 0.5;
let desired_width = available_size.x * target_width_ratio;
let scale = (desired_width / texture_size.x).min(1.0);

let display_size = texture_size * scale;

// Ensure vertical centering by adjusting spacing
let vertical_center_offset = (available_size.y - display_size.y) / 2.0;
ui.add_space(vertical_center_offset);

ui.add(egui::Image::new(texture).fit_to_original_size(scale));
log::trace!("Image added to frame {}", count);
}
None => {
ui.colored_label(Color32::RED, "Failed to load splash image");
log::error!("No texture available for splash screen");
}
}
});

// Request a repaint to ensure the timer updates even without mouse movement
ui.ctx().request_repaint();
}
113 changes: 113 additions & 0 deletions bitvault-ui/src/app/screens/lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use crate::app::state::{View, WalletState};
use crate::app::BitVaultApp;
use eframe::egui::{self, Color32, Ui};

pub fn render(app: &BitVaultApp, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(50.0);
ui.heading("Unlock Your Wallet");
ui.add_space(20.0);

ui.label("Enter your PIN to unlock your wallet");
ui.add_space(30.0);

// Check if we need to load the wallet data from disk
let wallet_loaded = if let Ok(mut state) = app.state.write() {
if state.encrypted_wallet_data.is_none() {
log::info!("Attempting to load wallet data from disk");
match app.load_wallet_from_disk() {
Ok(encrypted_data) => {
state.encrypted_wallet_data = Some(encrypted_data);
log::info!("Wallet data loaded from disk successfully");
true
}
Err(e) => {
log::error!("Failed to load wallet data: {}", e);
state.lock_error = Some(
"Failed to load wallet data. Please create a new wallet.".to_string(),
);
false
}
}
} else {
true
}
} else {
false
};

let mut pin_input = String::new();
if let Ok(state) = app.state.read() {
pin_input = state.pin_input.clone();
}

// PIN input field
let pin_response = ui.add(
egui::TextEdit::singleline(&mut pin_input)
.password(true)
.hint_text("Enter PIN")
.desired_width(200.0),
);

if pin_response.changed() {
if let Ok(mut state) = app.state.write() {
state.pin_input = pin_input;
}
}

// Check for Enter key press
let enter_pressed =
pin_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));

// Display error message if there is one
if let Ok(state) = app.state.read() {
if let Some(error) = &state.lock_error {
ui.add_space(10.0);
ui.colored_label(Color32::RED, error);
}
}

ui.add_space(20.0);

let unlock_button = ui.add_enabled(wallet_loaded, egui::Button::new("Unlock"));
if unlock_button.clicked() || (enter_pressed && wallet_loaded) {
if let Ok(mut state) = app.state.write() {
// Try to load and decrypt the wallet
if let Some(encrypted_data) = &state.encrypted_wallet_data {
log::info!("Attempting to decrypt wallet");
match bitvault_core::crypto::decrypt_seed(encrypted_data, &state.pin_input) {
Ok(seed_phrase) => {
// Successfully decrypted
state.seed_phrase = Some(seed_phrase);
state.wallet_state = WalletState::Unlocked;
state.current_view = View::Wallet;
state.lock_error = None;
state.pin_input.clear(); // Clear PIN input for security
log::info!("Wallet unlocked successfully");
}
Err(e) => {
// Failed to decrypt
log::error!("Failed to decrypt wallet: {}", e);
state.lock_error = Some("Incorrect PIN. Please try again.".to_string());
}
}
} else {
state.lock_error =
Some("No wallet data found. Please create a new wallet.".to_string());
}
}
}

ui.add_space(20.0);

if ui.button("Back to Home").clicked() {
if let Ok(mut state) = app.state.write() {
state.current_view = View::Home;
state.wallet_state = WalletState::New;
state.pin_input.clear();
state.lock_error = None;
log::info!("Returning to home screen");
}
}
});
}
Loading