diff --git a/.github/workflows/ql.yml b/.github/workflows/ql.yml index 77c6c05..031955f 100644 --- a/.github/workflows/ql.yml +++ b/.github/workflows/ql.yml @@ -1,8 +1,11 @@ -name: Code QL +name: CI on: push: branches: [main] + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' pull_request: branches: [main] @@ -12,7 +15,26 @@ permissions: security-events: write jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install linux deps + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev + + - name: Run tests + run: cargo test --verbose + analyze: + name: CodeQL Analysis runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae3f10b..0870634 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,46 @@ env: CARGO_TERM_COLOR: always jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install linux deps + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev + + - name: Run tests + run: cargo test --verbose + + analyze: + name: CodeQL Analysis + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: rust + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + build: name: Build ${{ matrix.target }} + needs: [test, analyze] runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/Cargo.lock b/Cargo.lock index 3f5a0bc..8f0e59b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,7 +258,7 @@ dependencies = [ [[package]] name = "bags" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5113913..d8a1c7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bags" -version = "0.1.1" -edition = "2021" +version = "0.1.2" +edition = "2024" license = "MIT" [dependencies] diff --git a/README.md b/README.md index ac56c0c..1353b57 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,18 @@ # bags ⚉ +[![CI](https://github.com/vdo/bags/actions/workflows/ql.yml/badge.svg)](https://github.com/vdo/bags/actions/workflows/ql.yml) +[![Release](https://github.com/vdo/bags/actions/workflows/release.yml/badge.svg)](https://github.com/vdo/bags/actions/workflows/release.yml) + A crypto price and portfolio tracker for the terminal. -``` - bags ⚉ Markets · Favourites · Portfolio │ MCap:3.2T BTC:61.2% F&G:72 Greed 32s ago -──────────────────────────────────────────────────────────────────────────────────────── - # Name Ticker Price 1h% 24h% 7d% 24h Hi 24h Lo Volume MCap - 1 Bitcoin BTC 97,342.10 +0.3% +2.1% +5.4% 98,100.00 95,200.00 32.1B 1.9T - 2 Ethereum ETH 3,241.55 -0.1% +1.8% +3.2% 3,300.10 3,180.00 18.4B 389.2B - ... -``` +![demo](assets/demo.gif) + ## Features - **Three tabs** -- Markets (top 50), Favourites, Portfolio - **Encrypted storage** -- SQLCipher-encrypted local database, password unlock on launch +- **Privacy lock** -- Auto-locks UI and DB after configurable minutes of inactivity (off/1/5/10/15/30) - **Price charts** -- Sparkline graphs with 1D/7D/30D views, supply info, active alerts - **Portfolio tracking** -- Add holdings, see total value, P&L and % gain per coin - **Profit & loss** -- Auto-records buy price on first add; override with `b` @@ -24,10 +22,11 @@ A crypto price and portfolio tracker for the terminal. - **Filter** -- Press `/` to fuzzy-filter coins by name or ticker in real time - **Mouse support** -- Click rows, scroll wheel, click tabs - **24h range** -- High/low columns in the table +- **Currency in headers** -- Price column shows active currency, e.g. `Price(USD)` - **Notifications** -- Desktop (notify-rust) and/or push (ntfy.sh), configurable in settings - **Custom coins** -- Search and add any coin from CoinGecko - **API key support** -- Optional CoinGecko Pro / CoinMarketCap keys -- **Multi-currency** -- USD, EUR, GBP, JPY, BTC, ETH, and more +- **Multi-currency** -- USD, EUR, GBP, JPY, AUD, CAD, CHF, CNY, KRW, INR, BRL, BTC, ETH - **11 color themes** -- Dark, dark-blue, dark-green, light, bubblegum, no-color, ... - **Configurable refresh** -- Default 60s, minimum 30s - **Error logging** -- Errors logged to `~/.config/bags/errors.log` with timestamps @@ -81,6 +80,7 @@ First run prompts you to set a password. Subsequent runs unlock with that passwo refresh_interval_secs: 60 currency: usd theme: dark +privacy_lock_minutes: 0 # 0 = off, or 1/5/10/15/30 ``` ## Data @@ -99,8 +99,6 @@ Press `S` to open settings. Use `j`/`k` to navigate, `h`/`l` to cycle options, ` - **CoinMarketCap API Key** -- Optional - **Notifications** -- none / desktop / ntfy / both - **Ntfy Topic** -- Your ntfy.sh topic for push alerts +- **Privacy Lock** -- Auto-lock after inactivity: off / 1 / 5 / 10 / 15 / 30 minutes --- - -> *"Everyone has a plan until they check their portfolio."* -> -- Mike Tyson, mass holder of bags diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..78d81b9 Binary files /dev/null and b/assets/demo.gif differ diff --git a/src/app.rs b/src/app.rs index cd11d07..1ddde06 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,10 +72,15 @@ pub struct App { pub ntfy_topic: String, pub settings_notification_idx: usize, pub settings_ntfy_topic: String, + pub settings_privacy_lock_idx: usize, // Error timing pub error_time: Option, // Buy price editing pub buy_price_buf: String, + // Privacy lock + pub last_activity: std::time::Instant, + pub privacy_locked: bool, + pub db_password: String, } impl App { @@ -138,12 +143,17 @@ impl App { ntfy_topic: String::new(), settings_notification_idx: 0, settings_ntfy_topic: String::new(), + settings_privacy_lock_idx: 0, error_time: None, buy_price_buf: String::new(), + last_activity: std::time::Instant::now(), + privacy_locked: false, + db_password: String::new(), } } pub fn unlock(&mut self, db: Db) { + self.db_password = self.password_buf.clone(); let db = Arc::new(Mutex::new(db)); self.db = Some(db); self.unlocked = true; @@ -323,7 +333,7 @@ impl App { SettingsField::CoingeckoApiKey => &mut self.settings_coingecko_key, SettingsField::CoinmarketcapApiKey => &mut self.settings_cmc_key, SettingsField::NtfyTopic => &mut self.settings_ntfy_topic, - SettingsField::Currency | SettingsField::Theme | SettingsField::Notifications => &mut self.settings_coingecko_key, // unused for cycle fields + SettingsField::Currency | SettingsField::Theme | SettingsField::Notifications | SettingsField::PrivacyLock => &mut self.settings_coingecko_key, // unused for cycle fields } } @@ -434,6 +444,10 @@ impl App { .position(|m| *m == notification_method_label(self.notification_method)) .unwrap_or(0); self.settings_ntfy_topic = self.ntfy_topic.clone(); + self.settings_privacy_lock_idx = PRIVACY_LOCK_OPTIONS + .iter() + .position(|&m| m == self.config.privacy_lock_minutes) + .unwrap_or(0); self.settings_field = SettingsField::Currency; self.settings_editing = false; self.input_mode = InputMode::Settings; @@ -448,6 +462,46 @@ impl App { } } + pub fn cycle_privacy_lock(&mut self, forward: bool) { + let len = PRIVACY_LOCK_OPTIONS.len(); + if forward { + self.settings_privacy_lock_idx = (self.settings_privacy_lock_idx + 1) % len; + } else { + self.settings_privacy_lock_idx = (self.settings_privacy_lock_idx + len - 1) % len; + } + } + + pub fn record_activity(&mut self) { + self.last_activity = std::time::Instant::now(); + } + + pub fn check_privacy_lock(&mut self) { + let mins = self.config.privacy_lock_minutes; + if mins == 0 || !self.unlocked || self.privacy_locked { + return; + } + if self.last_activity.elapsed() >= std::time::Duration::from_secs(mins * 60) { + self.privacy_lock(); + } + } + + pub fn privacy_lock(&mut self) { + self.privacy_locked = true; + self.password_buf.clear(); + self.password_error = None; + self.input_mode = InputMode::PrivacyLock; + self.popup_open = false; + self.sort_picking = false; + } + + pub fn privacy_unlock(&mut self) { + self.privacy_locked = false; + self.password_buf.clear(); + self.password_error = None; + self.input_mode = InputMode::Normal; + self.last_activity = std::time::Instant::now(); + } + pub fn buy_price_for(&self, coin_id: &str) -> Option { self.holdings .iter() @@ -473,3 +527,264 @@ pub fn log_error(msg: &str) { let _ = writeln!(f, "[{}] {}", now, msg); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn default_app() -> App { + App::new(Config::default(), false) + } + + fn make_coin(id: &str, name: &str, symbol: &str, price: f64, rank: u32) -> Coin { + Coin { + id: id.to_string(), + name: name.to_string(), + symbol: symbol.to_string(), + current_price: price, + market_cap: price * 1_000_000.0, + total_volume: price * 100_000.0, + price_change_percentage_1h_in_currency: Some(0.5), + price_change_percentage_24h_in_currency: Some(1.0), + price_change_percentage_7d_in_currency: Some(2.0), + market_cap_rank: Some(rank), + high_24h: Some(price * 1.05), + low_24h: Some(price * 0.95), + circulating_supply: Some(1_000_000.0), + max_supply: None, + } + } + + // -- App::new -- + + #[test] + fn new_app_defaults() { + let app = default_app(); + assert_eq!(app.tab, Tab::Markets); + assert_eq!(app.input_mode, InputMode::Password); + assert!(!app.unlocked); + assert!(app.coins.is_empty()); + assert!(app.favourites.is_empty()); + assert!(app.holdings.is_empty()); + assert_eq!(app.selected, 0); + assert!(!app.quit); + } + + #[test] + fn new_app_uses_config_theme() { + let mut cfg = Config::default(); + cfg.theme = "light".to_string(); + let app = App::new(cfg, false); + assert_eq!(app.config.theme, "light"); + } + + // -- Cycling -- + + #[test] + fn cycle_currency_forward() { + let mut app = default_app(); + assert_eq!(app.settings_currency_idx, 0); + app.cycle_currency(true); + assert_eq!(app.settings_currency_idx, 1); + } + + #[test] + fn cycle_currency_backward_wraps() { + let mut app = default_app(); + assert_eq!(app.settings_currency_idx, 0); + app.cycle_currency(false); + assert_eq!(app.settings_currency_idx, CURRENCIES.len() - 1); + } + + #[test] + fn cycle_theme_forward() { + let mut app = default_app(); + let initial = app.settings_theme_idx; + app.cycle_theme(true); + assert_eq!(app.settings_theme_idx, initial + 1); + } + + #[test] + fn cycle_theme_updates_live_preview() { + let mut app = default_app(); + app.cycle_theme(true); + let expected_name = THEME_NAMES[app.settings_theme_idx]; + let expected = theme::by_name(expected_name); + assert_eq!(format!("{:?}", app.theme.fg), format!("{:?}", expected.fg)); + } + + #[test] + fn cycle_notification_wraps() { + let mut app = default_app(); + for _ in 0..NOTIFICATION_METHODS.len() { + app.cycle_notification(true); + } + assert_eq!(app.settings_notification_idx, 0); + } + + // -- visible_coins -- + + #[test] + fn visible_coins_markets_shows_all() { + let mut app = default_app(); + app.tab = Tab::Markets; + app.coins = vec![ + make_coin("btc", "Bitcoin", "btc", 97000.0, 1), + make_coin("eth", "Ethereum", "eth", 3200.0, 2), + ]; + let visible = app.visible_coins(); + assert_eq!(visible.len(), 2); + } + + #[test] + fn visible_coins_portfolio_filters() { + let mut app = default_app(); + app.tab = Tab::Portfolio; + app.coins = vec![ + make_coin("btc", "Bitcoin", "btc", 97000.0, 1), + make_coin("eth", "Ethereum", "eth", 3200.0, 2), + ]; + app.holdings = vec![Holding { + coin_id: "btc".to_string(), + amount: 1.5, + buy_price: Some(50000.0), + }]; + let visible = app.visible_coins(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].1.id, "btc"); + } + + #[test] + fn visible_coins_filter_query() { + let mut app = default_app(); + app.tab = Tab::Markets; + app.coins = vec![ + make_coin("btc", "Bitcoin", "btc", 97000.0, 1), + make_coin("eth", "Ethereum", "eth", 3200.0, 2), + make_coin("sol", "Solana", "sol", 150.0, 5), + ]; + app.filter_query = "bit".to_string(); + let visible = app.visible_coins(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].1.id, "btc"); + } + + #[test] + fn visible_coins_sort_by_price_desc() { + let mut app = default_app(); + app.tab = Tab::Markets; + app.coins = vec![ + make_coin("sol", "Solana", "sol", 150.0, 5), + make_coin("btc", "Bitcoin", "btc", 97000.0, 1), + make_coin("eth", "Ethereum", "eth", 3200.0, 2), + ]; + app.sort_column = Some(SortColumn::Price); + app.sort_direction = SortDirection::Desc; + let visible = app.visible_coins(); + assert_eq!(visible[0].1.id, "btc"); + assert_eq!(visible[1].1.id, "eth"); + assert_eq!(visible[2].1.id, "sol"); + } + + // -- Portfolio value -- + + #[test] + fn total_portfolio_value_calculation() { + let mut app = default_app(); + app.coins = vec![ + make_coin("btc", "Bitcoin", "btc", 100.0, 1), + make_coin("eth", "Ethereum", "eth", 50.0, 2), + ]; + app.holdings = vec![ + Holding { coin_id: "btc".to_string(), amount: 2.0, buy_price: None }, + Holding { coin_id: "eth".to_string(), amount: 10.0, buy_price: None }, + ]; + assert!((app.total_portfolio_value() - 700.0).abs() < 0.01); + } + + #[test] + fn total_portfolio_value_empty() { + let app = default_app(); + assert_eq!(app.total_portfolio_value(), 0.0); + } + + #[test] + fn holding_for_existing() { + let mut app = default_app(); + app.holdings = vec![Holding { + coin_id: "btc".to_string(), + amount: 1.5, + buy_price: None, + }]; + assert!((app.holding_for("btc") - 1.5).abs() < 0.001); + } + + #[test] + fn holding_for_missing() { + let app = default_app(); + assert_eq!(app.holding_for("btc"), 0.0); + } + + #[test] + fn buy_price_for_existing() { + let mut app = default_app(); + app.holdings = vec![Holding { + coin_id: "btc".to_string(), + amount: 1.0, + buy_price: Some(50000.0), + }]; + assert_eq!(app.buy_price_for("btc"), Some(50000.0)); + } + + #[test] + fn buy_price_for_missing() { + let app = default_app(); + assert_eq!(app.buy_price_for("btc"), None); + } + + // -- clamp_selection -- + + #[test] + fn clamp_selection_empty() { + let mut app = default_app(); + app.selected = 5; + app.clamp_selection(); + assert_eq!(app.selected, 0); + } + + #[test] + fn clamp_selection_beyond_end() { + let mut app = default_app(); + app.coins = vec![ + make_coin("btc", "Bitcoin", "btc", 97000.0, 1), + make_coin("eth", "Ethereum", "eth", 3200.0, 2), + ]; + app.selected = 10; + app.clamp_selection(); + assert_eq!(app.selected, 1); + } + + // -- open_settings -- + + #[test] + fn open_settings_sets_mode() { + let mut app = default_app(); + app.input_mode = InputMode::Normal; + app.open_settings(); + assert_eq!(app.input_mode, InputMode::Settings); + assert_eq!(app.settings_field, SettingsField::Currency); + assert!(!app.settings_editing); + } + + // -- adjust_scroll -- + + #[test] + fn adjust_scroll_follows_selection() { + let mut app = default_app(); + app.page_height = 2; + app.selected = 5; + app.adjust_scroll(); + assert!(app.scroll_offset <= app.selected); + assert!(app.selected < app.scroll_offset + app.page_height); + } +} diff --git a/src/config.rs b/src/config.rs index 6700018..e469d06 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,8 @@ pub struct Config { pub currency: String, #[serde(default = "default_theme")] pub theme: String, + #[serde(default)] + pub privacy_lock_minutes: u64, } fn default_refresh() -> u64 { @@ -31,6 +33,7 @@ impl Default for Config { refresh_interval_secs: default_refresh(), currency: default_currency(), theme: default_theme(), + privacy_lock_minutes: 0, } } } @@ -69,3 +72,51 @@ impl Config { path } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_values() { + let cfg = Config::default(); + assert_eq!(cfg.refresh_interval_secs, 60); + assert_eq!(cfg.currency, "usd"); + assert_eq!(cfg.theme, "dark"); + } + + #[test] + fn config_serialize_deserialize() { + let cfg = Config { + refresh_interval_secs: 90, + currency: "eur".to_string(), + theme: "light".to_string(), + privacy_lock_minutes: 5, + }; + let yaml = serde_yaml::to_string(&cfg).unwrap(); + let parsed: Config = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed.refresh_interval_secs, 90); + assert_eq!(parsed.currency, "eur"); + assert_eq!(parsed.theme, "light"); + assert_eq!(parsed.privacy_lock_minutes, 5); + } + + #[test] + fn config_deserialize_missing_fields_uses_defaults() { + let yaml = "{}"; + let cfg: Config = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.refresh_interval_secs, 60); + assert_eq!(cfg.currency, "usd"); + assert_eq!(cfg.theme, "dark"); + assert_eq!(cfg.privacy_lock_minutes, 0); + } + + #[test] + fn config_deserialize_partial() { + let yaml = "currency: gbp\n"; + let cfg: Config = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.currency, "gbp"); + assert_eq!(cfg.refresh_interval_secs, 60); + assert_eq!(cfg.theme, "dark"); + } +} diff --git a/src/db.rs b/src/db.rs index 0193f5f..b08b565 100644 --- a/src/db.rs +++ b/src/db.rs @@ -220,4 +220,209 @@ impl Db { Ok(()) } + #[cfg(test)] + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory() + .context("Failed to open in-memory database")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS favourites ( + coin_id TEXT PRIMARY KEY + ); + CREATE TABLE IF NOT EXISTS holdings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coin_id TEXT NOT NULL UNIQUE, + amount REAL NOT NULL DEFAULT 0.0, + buy_price REAL + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS price_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coin_id TEXT NOT NULL, + target_price REAL NOT NULL, + direction TEXT NOT NULL DEFAULT 'above', + triggered INTEGER NOT NULL DEFAULT 0 + );", + )?; + Ok(Self { conn }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_db() -> Db { + Db::open_in_memory().unwrap() + } + + // -- Favourites -- + + #[test] + fn favourite_toggle_on_off() { + let db = test_db(); + assert!(!db.is_favourite("bitcoin")); + + let added = db.toggle_favourite("bitcoin").unwrap(); + assert!(added); + assert!(db.is_favourite("bitcoin")); + + let removed = db.toggle_favourite("bitcoin").unwrap(); + assert!(!removed); + assert!(!db.is_favourite("bitcoin")); + } + + #[test] + fn get_favourites_returns_all() { + let db = test_db(); + db.toggle_favourite("bitcoin").unwrap(); + db.toggle_favourite("ethereum").unwrap(); + let favs = db.get_favourites().unwrap(); + assert_eq!(favs.len(), 2); + assert!(favs.contains(&"bitcoin".to_string())); + assert!(favs.contains(&"ethereum".to_string())); + } + + #[test] + fn get_favourites_empty() { + let db = test_db(); + let favs = db.get_favourites().unwrap(); + assert!(favs.is_empty()); + } + + // -- Holdings -- + + #[test] + fn set_and_get_holding() { + let db = test_db(); + db.set_holding("bitcoin", 1.5, Some(50000.0)).unwrap(); + let holdings = db.get_holdings().unwrap(); + assert_eq!(holdings.len(), 1); + assert_eq!(holdings[0].coin_id, "bitcoin"); + assert!((holdings[0].amount - 1.5).abs() < 0.001); + assert_eq!(holdings[0].buy_price, Some(50000.0)); + } + + #[test] + fn set_holding_zero_removes() { + let db = test_db(); + db.set_holding("bitcoin", 1.5, None).unwrap(); + assert_eq!(db.get_holdings().unwrap().len(), 1); + + db.set_holding("bitcoin", 0.0, None).unwrap(); + assert!(db.get_holdings().unwrap().is_empty()); + } + + #[test] + fn set_holding_negative_removes() { + let db = test_db(); + db.set_holding("bitcoin", 1.0, None).unwrap(); + db.set_holding("bitcoin", -1.0, None).unwrap(); + assert!(db.get_holdings().unwrap().is_empty()); + } + + #[test] + fn set_holding_upsert_preserves_buy_price() { + let db = test_db(); + db.set_holding("bitcoin", 1.0, Some(50000.0)).unwrap(); + db.set_holding("bitcoin", 2.0, None).unwrap(); + let holdings = db.get_holdings().unwrap(); + assert_eq!(holdings[0].amount, 2.0); + assert_eq!(holdings[0].buy_price, Some(50000.0)); + } + + #[test] + fn set_buy_price() { + let db = test_db(); + db.set_holding("bitcoin", 1.0, None).unwrap(); + db.set_buy_price("bitcoin", 60000.0).unwrap(); + let holdings = db.get_holdings().unwrap(); + assert_eq!(holdings[0].buy_price, Some(60000.0)); + } + + // -- Settings -- + + #[test] + fn set_and_get_setting() { + let db = test_db(); + db.set_setting("currency", "eur").unwrap(); + assert_eq!(db.get_setting("currency"), Some("eur".to_string())); + } + + #[test] + fn get_setting_missing() { + let db = test_db(); + assert_eq!(db.get_setting("nonexistent"), None); + } + + #[test] + fn set_setting_empty_deletes() { + let db = test_db(); + db.set_setting("key", "value").unwrap(); + assert!(db.get_setting("key").is_some()); + + db.set_setting("key", "").unwrap(); + assert_eq!(db.get_setting("key"), None); + } + + #[test] + fn set_setting_upsert() { + let db = test_db(); + db.set_setting("key", "v1").unwrap(); + db.set_setting("key", "v2").unwrap(); + assert_eq!(db.get_setting("key"), Some("v2".to_string())); + } + + // -- Alerts -- + + #[test] + fn add_and_get_alerts() { + let db = test_db(); + db.add_alert("bitcoin", 100000.0, "above").unwrap(); + db.add_alert("ethereum", 2000.0, "below").unwrap(); + let alerts = db.get_alerts().unwrap(); + assert_eq!(alerts.len(), 2); + } + + #[test] + fn alert_direction_parsing() { + let db = test_db(); + db.add_alert("bitcoin", 100000.0, "above").unwrap(); + db.add_alert("ethereum", 2000.0, "below").unwrap(); + let alerts = db.get_alerts().unwrap(); + let btc_alert = alerts.iter().find(|a| a.coin_id == "bitcoin").unwrap(); + let eth_alert = alerts.iter().find(|a| a.coin_id == "ethereum").unwrap(); + assert_eq!(btc_alert.direction, AlertDirection::Above); + assert_eq!(eth_alert.direction, AlertDirection::Below); + } + + #[test] + fn mark_alert_triggered() { + let db = test_db(); + db.add_alert("bitcoin", 100000.0, "above").unwrap(); + let alerts = db.get_alerts().unwrap(); + assert!(!alerts[0].triggered); + + db.mark_alert_triggered("bitcoin", 100000.0).unwrap(); + let alerts = db.get_alerts().unwrap(); + assert!(alerts[0].triggered); + } + + #[test] + fn delete_alert() { + let db = test_db(); + db.add_alert("bitcoin", 100000.0, "above").unwrap(); + assert_eq!(db.get_alerts().unwrap().len(), 1); + + db.delete_alert("bitcoin", 100000.0).unwrap(); + assert!(db.get_alerts().unwrap().is_empty()); + } + + #[test] + fn get_alerts_empty() { + let db = test_db(); + assert!(db.get_alerts().unwrap().is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index 46b16a7..0e25ad4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,7 +164,10 @@ async fn run_app( } } - // Now unlocked -- create client and fetch data + // Now unlocked -- reset activity timer + app.record_activity(); + + // Create client and fetch data let client = CoinGeckoClient::new(&app.config.currency, &app.coingecko_api_key); app.refresh_db_state().await; app.refresh_market_data(&client).await; @@ -189,6 +192,9 @@ async fn run_main_loop( let mut bottom_bar_y: u16 = 0; loop { + // Check privacy lock timeout + app.check_privacy_lock(); + let refresh_dur = Duration::from_secs(app.config.refresh_interval_secs); app.update_refresh_display(); @@ -199,20 +205,57 @@ async fn run_main_loop( ui::draw(f, &mut *app); })?; - // Auto-refresh - if let Some(last) = app.last_refresh { - if last.elapsed() >= refresh_dur { - app.refresh_market_data(&client).await; - app.refresh_db_state().await; - app.clamp_selection(); - app.check_alerts(); - app.refresh_global_stats(&client).await; + // Auto-refresh (skip while privacy locked) + if !app.privacy_locked { + if let Some(last) = app.last_refresh { + if last.elapsed() >= refresh_dur { + app.refresh_market_data(&client).await; + app.refresh_db_state().await; + app.clamp_selection(); + app.check_alerts(); + app.refresh_global_stats(&client).await; + } } } if event::poll(tick_rate)? { let ev = event::read()?; + // Handle privacy lock input + if app.privacy_locked { + if let Event::Key(key) = ev { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.quit = true; + } else { + match key.code { + KeyCode::Esc => app.quit = true, + KeyCode::Enter => { + if app.password_buf == app.db_password { + app.privacy_unlock(); + } else { + app.password_error = Some("Wrong password".into()); + app.password_buf.clear(); + } + } + KeyCode::Backspace => { + app.password_buf.pop(); + } + KeyCode::Char(c) => { + app.password_buf.push(c); + } + _ => {} + } + } + } + if app.quit { + break; + } + continue; + } + + // Record activity for privacy lock timer + app.record_activity(); + // Handle mouse events if let Event::Mouse(mouse) = ev { match mouse.kind { @@ -763,6 +806,7 @@ async fn handle_settings_key(app: &mut App, key: KeyCode, client: &mut CoinGecko SettingsField::Currency => app.cycle_currency(false), SettingsField::Theme => app.cycle_theme(false), SettingsField::Notifications => app.cycle_notification(false), + SettingsField::PrivacyLock => app.cycle_privacy_lock(false), _ => {} } } @@ -771,6 +815,7 @@ async fn handle_settings_key(app: &mut App, key: KeyCode, client: &mut CoinGecko SettingsField::Currency => app.cycle_currency(true), SettingsField::Theme => app.cycle_theme(true), SettingsField::Notifications => app.cycle_notification(true), + SettingsField::PrivacyLock => app.cycle_privacy_lock(true), _ => {} } } @@ -796,6 +841,7 @@ async fn handle_settings_key(app: &mut App, key: KeyCode, client: &mut CoinGecko app.theme = theme::by_name(&new_theme_name); app.notification_method = notification_method_from_str(new_notif); app.ntfy_topic = app.settings_ntfy_topic.clone(); + app.config.privacy_lock_minutes = PRIVACY_LOCK_OPTIONS[app.settings_privacy_lock_idx]; let _ = app.config.save(); // Recreate client with new key/currency diff --git a/src/theme.rs b/src/theme.rs index 1390fc3..ff2e215 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -244,3 +244,53 @@ pub fn no_color() -> Theme { error: Color::Reset, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_theme_names_resolve() { + for name in THEME_NAMES { + let _theme = by_name(name); + } + } + + #[test] + fn unknown_theme_falls_back_to_dark() { + let fallback = by_name("nonexistent"); + let dark = dark(); + assert_eq!(format!("{:?}", fallback.fg), format!("{:?}", dark.fg)); + assert_eq!(format!("{:?}", fallback.bg), format!("{:?}", dark.bg)); + } + + #[test] + fn default_is_dark() { + let def = Theme::default(); + let dark = dark(); + assert_eq!(format!("{:?}", def.fg), format!("{:?}", dark.fg)); + } + + #[test] + fn light_themes_have_non_reset_bg() { + let light_t = light(); + assert_ne!(light_t.bg, Color::Reset); + + let sol_light = solarized_light(); + assert_ne!(sol_light.bg, Color::Reset); + } + + #[test] + fn dark_themes_use_reset_bg() { + let themes = ["dark", "dark-blue", "dark-green", "dark-red", "dark-violet", "dark-gray"]; + for name in themes { + let t = by_name(name); + assert_eq!(t.bg, Color::Reset, "{} should use Reset bg", name); + } + } + + #[test] + fn theme_names_count() { + assert_eq!(THEME_NAMES.len(), 11); + } +} diff --git a/src/types.rs b/src/types.rs index 004bb75..4bd2635 100644 --- a/src/types.rs +++ b/src/types.rs @@ -186,6 +186,7 @@ pub enum InputMode { Filtering, EditingAlert, EditingBuyPrice, + PrivacyLock, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -196,6 +197,7 @@ pub enum SettingsField { CoinmarketcapApiKey, Notifications, NtfyTopic, + PrivacyLock, } impl SettingsField { @@ -207,6 +209,7 @@ impl SettingsField { SettingsField::CoinmarketcapApiKey => "CoinMarketCap API Key", SettingsField::Notifications => "Notifications", SettingsField::NtfyTopic => "Ntfy Topic", + SettingsField::PrivacyLock => "Privacy Lock", } } @@ -217,18 +220,20 @@ impl SettingsField { SettingsField::CoingeckoApiKey => SettingsField::CoinmarketcapApiKey, SettingsField::CoinmarketcapApiKey => SettingsField::Notifications, SettingsField::Notifications => SettingsField::NtfyTopic, - SettingsField::NtfyTopic => SettingsField::Currency, + SettingsField::NtfyTopic => SettingsField::PrivacyLock, + SettingsField::PrivacyLock => SettingsField::Currency, } } pub fn prev(self) -> Self { match self { - SettingsField::Currency => SettingsField::NtfyTopic, + SettingsField::Currency => SettingsField::PrivacyLock, SettingsField::Theme => SettingsField::Currency, SettingsField::CoingeckoApiKey => SettingsField::Theme, SettingsField::CoinmarketcapApiKey => SettingsField::CoingeckoApiKey, SettingsField::Notifications => SettingsField::CoinmarketcapApiKey, SettingsField::NtfyTopic => SettingsField::Notifications, + SettingsField::PrivacyLock => SettingsField::NtfyTopic, } } @@ -237,7 +242,7 @@ impl SettingsField { } pub fn is_cycle_field(self) -> bool { - matches!(self, SettingsField::Currency | SettingsField::Theme | SettingsField::Notifications) + matches!(self, SettingsField::Currency | SettingsField::Theme | SettingsField::Notifications | SettingsField::PrivacyLock) } } @@ -247,6 +252,8 @@ pub const CURRENCIES: &[&str] = &[ pub const NOTIFICATION_METHODS: &[&str] = &["none", "desktop", "ntfy", "both"]; +pub const PRIVACY_LOCK_OPTIONS: &[u64] = &[0, 1, 5, 10, 15, 30]; + pub fn notification_method_from_str(s: &str) -> NotificationMethod { match s { "desktop" => NotificationMethod::Desktop, @@ -280,3 +287,262 @@ pub fn currency_symbol(code: &str) -> &'static str { _ => "$", } } + +#[cfg(test)] +mod tests { + use super::*; + + // -- Tab -- + + #[test] + fn tab_index_roundtrip() { + for i in 0..3 { + assert_eq!(Tab::from_index(i).index(), i); + } + } + + #[test] + fn tab_from_index_out_of_range() { + assert_eq!(Tab::from_index(99), Tab::Markets); + } + + #[test] + fn tab_next_wraps() { + assert_eq!(Tab::Markets.next(), Tab::Favourites); + assert_eq!(Tab::Favourites.next(), Tab::Portfolio); + assert_eq!(Tab::Portfolio.next(), Tab::Markets); + } + + #[test] + fn tab_labels() { + assert_eq!(Tab::Markets.label(), "Markets"); + assert_eq!(Tab::Favourites.label(), "Favourites"); + assert_eq!(Tab::Portfolio.label(), "Portfolio"); + } + + // -- ChartView -- + + #[test] + fn chart_view_days() { + assert_eq!(ChartView::Day1.days(), 1); + assert_eq!(ChartView::Day7.days(), 7); + assert_eq!(ChartView::Day30.days(), 30); + } + + #[test] + fn chart_view_next_wraps() { + assert_eq!(ChartView::Day1.next(), ChartView::Day7); + assert_eq!(ChartView::Day7.next(), ChartView::Day30); + assert_eq!(ChartView::Day30.next(), ChartView::Day1); + } + + #[test] + fn chart_view_prev_wraps() { + assert_eq!(ChartView::Day1.prev(), ChartView::Day30); + assert_eq!(ChartView::Day30.prev(), ChartView::Day7); + assert_eq!(ChartView::Day7.prev(), ChartView::Day1); + } + + #[test] + fn chart_view_next_prev_inverse() { + let views = [ChartView::Day1, ChartView::Day7, ChartView::Day30]; + for v in views { + assert_eq!(v.next().prev(), v); + assert_eq!(v.prev().next(), v); + } + } + + #[test] + fn chart_view_labels() { + assert_eq!(ChartView::Day1.label(), "1D"); + assert_eq!(ChartView::Day7.label(), "7D"); + assert_eq!(ChartView::Day30.label(), "30D"); + } + + // -- SettingsField -- + + #[test] + fn settings_field_next_cycles_all() { + let start = SettingsField::Currency; + let mut current = start.next(); + let mut count = 1; + while current != start { + current = current.next(); + count += 1; + } + assert_eq!(count, 7); + } + + #[test] + fn settings_field_prev_cycles_all() { + let start = SettingsField::Currency; + let mut current = start.prev(); + let mut count = 1; + while current != start { + current = current.prev(); + count += 1; + } + assert_eq!(count, 7); + } + + #[test] + fn settings_field_next_prev_inverse() { + let fields = [ + SettingsField::Currency, + SettingsField::Theme, + SettingsField::CoingeckoApiKey, + SettingsField::CoinmarketcapApiKey, + SettingsField::Notifications, + SettingsField::NtfyTopic, + SettingsField::PrivacyLock, + ]; + for f in fields { + assert_eq!(f.next().prev(), f); + assert_eq!(f.prev().next(), f); + } + } + + #[test] + fn settings_field_text_vs_cycle() { + assert!(SettingsField::Currency.is_cycle_field()); + assert!(SettingsField::Theme.is_cycle_field()); + assert!(SettingsField::Notifications.is_cycle_field()); + assert!(SettingsField::PrivacyLock.is_cycle_field()); + assert!(!SettingsField::Currency.is_text_field()); + + assert!(SettingsField::CoingeckoApiKey.is_text_field()); + assert!(SettingsField::CoinmarketcapApiKey.is_text_field()); + assert!(SettingsField::NtfyTopic.is_text_field()); + assert!(!SettingsField::CoingeckoApiKey.is_cycle_field()); + } + + #[test] + fn settings_field_labels_non_empty() { + let fields = [ + SettingsField::Currency, + SettingsField::Theme, + SettingsField::CoingeckoApiKey, + SettingsField::CoinmarketcapApiKey, + SettingsField::Notifications, + SettingsField::NtfyTopic, + SettingsField::PrivacyLock, + ]; + for f in fields { + assert!(!f.label().is_empty()); + } + } + + // -- Notification helpers -- + + #[test] + fn notification_method_roundtrip() { + let methods = [ + NotificationMethod::None, + NotificationMethod::Desktop, + NotificationMethod::Ntfy, + NotificationMethod::Both, + ]; + for m in methods { + assert_eq!(notification_method_from_str(notification_method_label(m)), m); + } + } + + #[test] + fn notification_method_from_str_unknown() { + assert_eq!(notification_method_from_str("garbage"), NotificationMethod::None); + assert_eq!(notification_method_from_str(""), NotificationMethod::None); + } + + // -- Currency -- + + #[test] + fn currency_symbol_known() { + assert_eq!(currency_symbol("usd"), "$"); + assert_eq!(currency_symbol("eur"), "\u{20ac}"); + assert_eq!(currency_symbol("gbp"), "\u{a3}"); + assert_eq!(currency_symbol("btc"), "\u{20bf}"); + assert_eq!(currency_symbol("eth"), "\u{39e}"); + assert_eq!(currency_symbol("chf"), "Fr"); + assert_eq!(currency_symbol("brl"), "R$"); + } + + #[test] + fn currency_symbol_unknown_defaults() { + assert_eq!(currency_symbol("xyz"), "$"); + } + + #[test] + fn all_currencies_have_symbols() { + for c in CURRENCIES { + let sym = currency_symbol(c); + assert!(!sym.is_empty(), "currency {} has empty symbol", c); + } + } + + // -- Coin deserialization -- + + #[test] + fn coin_deserialize_null_prices() { + let json = r#"{ + "id": "bitcoin", + "name": "Bitcoin", + "symbol": "btc", + "current_price": null, + "market_cap": null, + "total_volume": null, + "price_change_percentage_1h_in_currency": null, + "price_change_percentage_24h_in_currency": null, + "price_change_percentage_7d_in_currency": null, + "market_cap_rank": null, + "high_24h": null, + "low_24h": null, + "circulating_supply": null, + "max_supply": null + }"#; + let coin: Coin = serde_json::from_str(json).unwrap(); + assert_eq!(coin.id, "bitcoin"); + assert_eq!(coin.current_price, 0.0); + assert_eq!(coin.market_cap, 0.0); + assert_eq!(coin.total_volume, 0.0); + } + + #[test] + fn coin_deserialize_with_values() { + let json = r#"{ + "id": "ethereum", + "name": "Ethereum", + "symbol": "eth", + "current_price": 3241.55, + "market_cap": 389200000000.0, + "total_volume": 18400000000.0, + "price_change_percentage_1h_in_currency": -0.1, + "price_change_percentage_24h_in_currency": 1.8, + "price_change_percentage_7d_in_currency": 3.2, + "market_cap_rank": 2, + "high_24h": 3300.10, + "low_24h": 3180.00, + "circulating_supply": 120000000.0, + "max_supply": null + }"#; + let coin: Coin = serde_json::from_str(json).unwrap(); + assert_eq!(coin.id, "ethereum"); + assert_eq!(coin.symbol, "eth"); + assert!((coin.current_price - 3241.55).abs() < 0.01); + assert_eq!(coin.market_cap_rank, Some(2)); + assert_eq!(coin.max_supply, None); + } + + // -- Constants -- + + #[test] + fn currencies_not_empty() { + assert!(!CURRENCIES.is_empty()); + assert!(CURRENCIES.contains(&"usd")); + } + + #[test] + fn notification_methods_not_empty() { + assert!(!NOTIFICATION_METHODS.is_empty()); + assert!(NOTIFICATION_METHODS.contains(&"none")); + } +} diff --git a/src/ui/render.rs b/src/ui/render.rs index 3c08e6f..47d1376 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -22,6 +22,10 @@ pub fn draw(f: &mut Frame, app: &mut App) { draw_lock_screen(f, app); return; } + InputMode::PrivacyLock => { + draw_privacy_lock_screen(f, app); + return; + } _ => {} } @@ -82,7 +86,8 @@ fn draw_lock_screen(f: &mut Frame, app: &App) { let block = Block::default() .title(" bags ") .borders(Borders::ALL) - .border_style(Style::default().fg(t.border)); + .border_style(Style::default().fg(t.border)) + .style(Style::default().bg(t.bg)); let inner = block.inner(popup); f.render_widget(block, popup); @@ -126,6 +131,66 @@ fn draw_lock_screen(f: &mut Frame, app: &App) { } } +// -- Privacy lock screen -- + +fn draw_privacy_lock_screen(f: &mut Frame, app: &App) { + let t = &app.theme; + let area = f.area(); + + let box_w = 44_u16.min(area.width.saturating_sub(4)); + let has_error = app.password_error.is_some(); + let box_h = if has_error { 7 } else { 6_u16 }; + let x = (area.width.saturating_sub(box_w)) / 2; + let y = (area.height.saturating_sub(box_h)) / 2; + let popup = Rect::new(x, y, box_w, box_h); + + f.render_widget(Clear, popup); + + let block = Block::default() + .title(" bags \u{1f512} ") + .borders(Borders::ALL) + .border_style(Style::default().fg(t.border)) + .style(Style::default().bg(t.bg)); + + let inner = block.inner(popup); + f.render_widget(block, popup); + + let mut constraints = vec![ + Constraint::Length(1), // locked label + Constraint::Length(1), // prompt + Constraint::Length(1), // input + ]; + if has_error { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Min(0)); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + let locked_label = Paragraph::new(" Locked due to inactivity") + .style(Style::default().fg(t.dim)); + f.render_widget(locked_label, chunks[0]); + + let prompt = Paragraph::new(" Enter password to unlock:") + .style(Style::default().fg(t.dim)); + f.render_widget(prompt, chunks[1]); + + let dots = "\u{2022}".repeat(app.password_buf.len()); + let input_line = format!(" {}_", dots); + let input_p = Paragraph::new(input_line) + .style(Style::default().fg(t.fg)); + f.render_widget(input_p, chunks[2]); + + if let Some(ref err) = app.password_error { + let err_p = Paragraph::new(format!(" {}", err)) + .style(Style::default().fg(t.error)); + f.render_widget(err_p, chunks[3]); + } +} + // -- Top bar -- fn draw_top_bar(f: &mut Frame, app: &App, area: Rect) { @@ -168,7 +233,7 @@ fn draw_top_bar(f: &mut Frame, app: &App, area: Rect) { format!("BTC Dom:{:.1}% ", stats.btc_dominance), Style::default().fg(t.accent), )); - if let (Some(idx), Some(ref label)) = (stats.fear_greed_index, &stats.fear_greed_label) { + if let (Some(idx), Some(label)) = (stats.fear_greed_index, &stats.fear_greed_label) { let fg_color = if idx >= 60 { t.positive } else if idx <= 40 { t.negative } else { t.dim }; spans.push(Span::styled( format!("F&G:{} {} ", idx, label), @@ -257,8 +322,22 @@ fn draw_main(f: &mut Frame, app: &mut App, area: Rect) { let is_portfolio = app.tab == Tab::Portfolio; - let header_cells = { - let mut h = vec![ + let header_cells = if is_portfolio { + vec![ + format!("#{}", sort_indicator(app, SortColumn::Rank)), + format!("Name{}", sort_indicator(app, SortColumn::Name)), + "Ticker".to_string(), + format!("Price{}", sort_indicator(app, SortColumn::Price)), + format!("1h%{}", sort_indicator(app, SortColumn::Change1h)), + format!("24h%{}", sort_indicator(app, SortColumn::Change24h)), + format!("7d%{}", sort_indicator(app, SortColumn::Change7d)), + "Qty".to_string(), + "Value".to_string(), + "P&L".to_string(), + "P&L%".to_string(), + ] + } else { + vec![ format!("#{}", sort_indicator(app, SortColumn::Rank)), format!("Name{}", sort_indicator(app, SortColumn::Name)), "Ticker".to_string(), @@ -270,14 +349,7 @@ fn draw_main(f: &mut Frame, app: &mut App, area: Rect) { "24h Lo".to_string(), format!("Volume{}", sort_indicator(app, SortColumn::Volume)), format!("MCap{}", sort_indicator(app, SortColumn::MarketCap)), - ]; - if is_portfolio { - h.push("Qty".to_string()); - h.push("Value".to_string()); - h.push("P&L".to_string()); - h.push("P&L%".to_string()); - } - h + ] }; let header = Row::new( @@ -325,10 +397,6 @@ fn draw_main(f: &mut Frame, app: &mut App, area: Rect) { pct_cell(coin.price_change_percentage_1h_in_currency, &h1, positive, negative, dim), pct_cell(coin.price_change_percentage_24h_in_currency, &h24, positive, negative, dim), pct_cell(coin.price_change_percentage_7d_in_currency, &d7, positive, negative, dim), - Cell::from(hi24).style(Style::default().fg(dim)), - Cell::from(lo24).style(Style::default().fg(dim)), - Cell::from(vol).style(Style::default().fg(dim)), - Cell::from(mcap).style(Style::default().fg(dim)), ]; if is_portfolio { @@ -353,6 +421,11 @@ fn draw_main(f: &mut Frame, app: &mut App, area: Rect) { cells.push(Cell::from("--").style(Style::default().fg(dim))); cells.push(Cell::from("--").style(Style::default().fg(dim))); } + } else { + cells.push(Cell::from(hi24).style(Style::default().fg(dim))); + cells.push(Cell::from(lo24).style(Style::default().fg(dim))); + cells.push(Cell::from(vol).style(Style::default().fg(dim))); + cells.push(Cell::from(mcap).style(Style::default().fg(dim))); } let is_flashing = flash_coin_id.as_deref() == Some(&coin.id); @@ -378,12 +451,8 @@ fn draw_main(f: &mut Frame, app: &mut App, area: Rect) { Constraint::Length(8), Constraint::Length(8), Constraint::Length(10), - Constraint::Length(10), - Constraint::Length(9), - Constraint::Length(9), - Constraint::Length(10), - Constraint::Length(10), - Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(12), Constraint::Length(8), ] } else { @@ -402,25 +471,42 @@ fn draw_main(f: &mut Frame, app: &mut App, area: Rect) { ] }; - let mut block = Block::default().borders(Borders::NONE); - if is_portfolio { let total = app.total_portfolio_value(); - block = block.title(Line::from(vec![ - Span::styled(" Total: ", Style::default().fg(t.dim)), - Span::styled( - format!("{}{} ", currency_symbol(&app.config.currency), format_price(total)), - Style::default().fg(t.title).add_modifier(Modifier::BOLD), - ), - ])).title_alignment(ratatui::layout::Alignment::Right); - } - - let table = Table::new(rows, &widths) - .header(header) - .block(block) - .column_spacing(1); + let total_text = format!(" Total: {}{} ", currency_symbol(&app.config.currency), format_price(total)); - f.render_widget(table, area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(area); + + let table = Table::new(rows, &widths) + .header(header) + .block(Block::default().borders(Borders::NONE)) + .column_spacing(1); + f.render_widget(table, chunks[0]); + + let total_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(t.accent)) + .style(Style::default().bg(t.bg)); + let total_inner = total_block.inner(chunks[1]); + f.render_widget(total_block, chunks[1]); + + let total_p = Paragraph::new(total_text) + .style(Style::default().fg(t.title).add_modifier(Modifier::BOLD)) + .alignment(ratatui::layout::Alignment::Right); + f.render_widget(total_p, total_inner); + } else { + let table = Table::new(rows, &widths) + .header(header) + .block(Block::default().borders(Borders::NONE)) + .column_spacing(1); + f.render_widget(table, area); + } } // -- Bottom bar -- @@ -495,7 +581,8 @@ fn draw_popup(f: &mut Frame, app: &App) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(t.accent)); + .border_style(Style::default().fg(t.accent)) + .style(Style::default().bg(t.bg)); let inner = block.inner(area); f.render_widget(block, area); @@ -634,7 +721,8 @@ fn draw_input_popup(f: &mut Frame, app: &App) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(t.input_accent)); + .border_style(Style::default().fg(t.input_accent)) + .style(Style::default().bg(t.bg)); let inner = block.inner(area); f.render_widget(block, area); @@ -667,7 +755,8 @@ fn draw_alert_popup(f: &mut Frame, app: &App) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(t.input_accent)); + .border_style(Style::default().fg(t.input_accent)) + .style(Style::default().bg(t.bg)); let inner = block.inner(area); f.render_widget(block, area); @@ -732,7 +821,8 @@ fn draw_buyprice_popup(f: &mut Frame, app: &App) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(t.input_accent)); + .border_style(Style::default().fg(t.input_accent)) + .style(Style::default().bg(t.bg)); let inner = block.inner(area); f.render_widget(block, area); @@ -754,7 +844,7 @@ fn draw_settings(f: &mut Frame, app: &App) { let t = &app.theme; let area = f.area(); let box_w = 60_u16.min(area.width.saturating_sub(4)); - let box_h = 26_u16.min(area.height.saturating_sub(2)); + let box_h = 29_u16.min(area.height.saturating_sub(2)); let x = (area.width.saturating_sub(box_w)) / 2; let y = (area.height.saturating_sub(box_h)) / 2; let popup = Rect::new(x, y, box_w, box_h); @@ -764,7 +854,8 @@ fn draw_settings(f: &mut Frame, app: &App) { let block = Block::default() .title(" Settings ") .borders(Borders::ALL) - .border_style(Style::default().fg(t.accent)); + .border_style(Style::default().fg(t.accent)) + .style(Style::default().bg(t.bg)); let inner = block.inner(popup); f.render_widget(block, popup); @@ -791,7 +882,10 @@ fn draw_settings(f: &mut Frame, app: &App) { Constraint::Length(1), // [16] ntfy topic label Constraint::Length(1), // [17] ntfy topic value Constraint::Length(1), // [18] blank - Constraint::Length(1), // [19] hint + Constraint::Length(1), // [19] privacy lock label + Constraint::Length(1), // [20] privacy lock value + Constraint::Length(1), // [21] blank + Constraint::Length(1), // [22] hint Constraint::Min(0), ]) .split(inner); @@ -839,6 +933,19 @@ fn draw_settings(f: &mut Frame, app: &App) { false, ); + // -- Privacy lock -- + let lock_val = PRIVACY_LOCK_OPTIONS[app.settings_privacy_lock_idx]; + let lock_label = if lock_val == 0 { + "off".to_string() + } else { + format!("{} min", lock_val) + }; + draw_cycle_field(f, t, chunks[19], chunks[20], + app.settings_field == SettingsField::PrivacyLock, + "Privacy Lock", + &lock_label, + ); + let hint = if app.settings_editing { " Enter/Esc finish editing" } else if app.settings_field.is_cycle_field() { @@ -848,7 +955,7 @@ fn draw_settings(f: &mut Frame, app: &App) { }; let hint_p = Paragraph::new(hint) .style(Style::default().fg(t.dim)); - f.render_widget(hint_p, chunks[19]); + f.render_widget(hint_p, chunks[22]); } fn draw_cycle_field(f: &mut Frame, t: &crate::theme::Theme, label_area: Rect, value_area: Rect, is_selected: bool, label: &str, value: &str) { @@ -924,7 +1031,8 @@ fn draw_search(f: &mut Frame, app: &App) { let block = Block::default() .title(" Add coin ") .borders(Borders::ALL) - .border_style(Style::default().fg(t.accent)); + .border_style(Style::default().fg(t.accent)) + .style(Style::default().bg(t.bg)); let inner = block.inner(popup); f.render_widget(block, popup);