Skip to content

Commit c44aaba

Browse files
committed
feat: add donation wall page with photos
Adds a "Donation Wall" page (reachable from a new Main page card) that lists recent donations with their photo, falling back to the Guy Fawkes icon for anonymous donations or when no photo is available. Tapping a thumbnail toggles a fullscreen view of the same picture. Donations are now recorded in a new donation_log SQLite table (timestamp, username, amount, fund) whenever a donation succeeds, on both the manual "Done" and inactivity auto-approve paths. The photo filename is now deterministic (camera::photo_filename) so the wall can re-associate a logged donation with its saved photo without a separate DB column.
1 parent db2b773 commit c44aaba

6 files changed

Lines changed: 446 additions & 32 deletions

File tree

src/camera.rs

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,36 @@ use nokhwa::utils::{
77
use std::path::PathBuf;
88
use std::sync::mpsc::{Receiver, SyncSender, TryRecvError};
99
use std::thread;
10-
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10+
use std::time::{Duration, Instant};
11+
12+
/// Deterministic filename for a donation photo. Shared with `donation_log` so
13+
/// a logged donation (timestamp, username) can be re-associated with the
14+
/// photo file that `capture_donation_photo` saved for it, without needing to
15+
/// store the path separately.
16+
pub fn photo_filename(timestamp: u64, username: &str) -> String {
17+
let safe_username: String = username
18+
.chars()
19+
.map(|c| {
20+
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
21+
c
22+
} else {
23+
'_'
24+
}
25+
})
26+
.collect();
27+
format!("{timestamp}_{safe_username}.jpg")
28+
}
1129

1230
/// Captures a single frame from the default webcam and saves it as a JPEG
1331
/// under `photos_dir`, running on a dedicated thread so it never blocks the UI.
14-
pub fn capture_donation_photo(photos_dir: &str, username: &str) {
32+
/// `timestamp` is supplied by the caller (rather than generated here) so it
33+
/// can match the timestamp recorded in the donation log.
34+
pub fn capture_donation_photo(photos_dir: &str, username: &str, timestamp: u64) {
1535
let photos_dir = photos_dir.to_string();
1636
let username = username.to_string();
1737

1838
thread::spawn(move || {
19-
if let Err(e) = capture_and_save(&photos_dir, &username) {
39+
if let Err(e) = capture_and_save(&photos_dir, &username, timestamp) {
2040
error!("📷 Failed to take donation photo: {}", e);
2141
}
2242
});
@@ -56,7 +76,7 @@ fn open_preview_camera() -> Result<Camera, String> {
5676
open_camera()
5777
}
5878

59-
fn capture_and_save(photos_dir: &str, username: &str) -> Result<(), String> {
79+
fn capture_and_save(photos_dir: &str, username: &str, timestamp: u64) -> Result<(), String> {
6080
let mut camera = open_camera()?;
6181

6282
// Discard the first couple of frames to let auto-exposure/white-balance settle.
@@ -74,21 +94,7 @@ fn capture_and_save(photos_dir: &str, username: &str) -> Result<(), String> {
7494
std::fs::create_dir_all(photos_dir)
7595
.map_err(|e| format!("failed to create photos directory {photos_dir}: {e}"))?;
7696

77-
let timestamp = SystemTime::now()
78-
.duration_since(UNIX_EPOCH)
79-
.map(|d| d.as_secs())
80-
.unwrap_or(0);
81-
let safe_username: String = username
82-
.chars()
83-
.map(|c| {
84-
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
85-
c
86-
} else {
87-
'_'
88-
}
89-
})
90-
.collect();
91-
let path = PathBuf::from(photos_dir).join(format!("{timestamp}_{safe_username}.jpg"));
97+
let path = PathBuf::from(photos_dir).join(photo_filename(timestamp, username));
9298

9399
image
94100
.save(&path)

src/donation_log.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use log::error;
2+
use rusqlite::{Connection, Result as SqlResult, params};
3+
use std::thread;
4+
use std::time::{SystemTime, UNIX_EPOCH};
5+
6+
/// A single completed donation, as shown on the donation wall.
7+
#[derive(Debug, Clone)]
8+
pub struct DonationLogEntry {
9+
pub timestamp: u64,
10+
pub username: String,
11+
pub amount: i32,
12+
pub fund_name: String,
13+
}
14+
15+
fn init_db(db: &Connection) -> SqlResult<()> {
16+
db.execute(
17+
"CREATE TABLE IF NOT EXISTS donation_log (
18+
id INTEGER PRIMARY KEY AUTOINCREMENT,
19+
timestamp INTEGER NOT NULL,
20+
username TEXT NOT NULL,
21+
amount INTEGER NOT NULL,
22+
fund_name TEXT NOT NULL
23+
)",
24+
[],
25+
)?;
26+
Ok(())
27+
}
28+
29+
/// Current unix timestamp, shared between a donation's log row and its photo
30+
/// filename (see `camera::photo_filename`) so the two can be re-associated.
31+
pub fn now_timestamp() -> u64 {
32+
SystemTime::now()
33+
.duration_since(UNIX_EPOCH)
34+
.map(|d| d.as_secs())
35+
.unwrap_or(0)
36+
}
37+
38+
/// Records a completed donation, running on a dedicated thread so it never
39+
/// blocks the donation flow. Best-effort: a DB hiccup is logged and dropped.
40+
pub fn record(db_path: &str, timestamp: u64, username: &str, amount: i32, fund_name: &str) {
41+
let db_path = db_path.to_string();
42+
let username = username.to_string();
43+
let fund_name = fund_name.to_string();
44+
45+
thread::spawn(move || {
46+
let result = (|| -> SqlResult<()> {
47+
let db = Connection::open(&db_path)?;
48+
init_db(&db)?;
49+
db.execute(
50+
"INSERT INTO donation_log (timestamp, username, amount, fund_name) VALUES (?1, ?2, ?3, ?4)",
51+
params![timestamp as i64, username, amount, fund_name],
52+
)?;
53+
Ok(())
54+
})();
55+
56+
if let Err(e) = result {
57+
error!("Failed to record donation log entry: {}", e);
58+
}
59+
});
60+
}
61+
62+
/// Fetches the most recent donations, newest first. Blocking — call off the UI thread.
63+
pub fn fetch_recent(db_path: &str, limit: i64) -> SqlResult<Vec<DonationLogEntry>> {
64+
let db = Connection::open(db_path)?;
65+
init_db(&db)?;
66+
67+
let mut stmt = db.prepare(
68+
"SELECT timestamp, username, amount, fund_name FROM donation_log ORDER BY timestamp DESC LIMIT ?1",
69+
)?;
70+
let rows = stmt.query_map([limit], |row| {
71+
Ok(DonationLogEntry {
72+
timestamp: row.get::<_, i64>(0)? as u64,
73+
username: row.get(1)?,
74+
amount: row.get(2)?,
75+
fund_name: row.get(3)?,
76+
})
77+
})?;
78+
rows.collect()
79+
}

src/main.rs

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod cctalk;
99
mod config;
1010
mod diag_logger;
1111
mod donation;
12+
mod donation_log;
1213
mod error;
1314
mod funds;
1415
mod home_assistant;
@@ -69,6 +70,7 @@ pub fn main() {
6970
donation_handler::init(&main_window, &config, cashcode_tx, cctalk_tx);
7071
home_assistant_handler::init(&main_window, &config);
7172
game_handler::init(&main_window, &config);
73+
logs_handler::init(&main_window, &config);
7274

7375
main_window.run().unwrap();
7476
}
@@ -599,6 +601,7 @@ mod donation_handler {
599601
cashcode_tx: Sender<bill_acceptor::CashCodeCommand>,
600602
token: Option<String>,
601603
photos_dir: String,
604+
stats_db_path: String,
602605
) -> slint::Timer {
603606
let timer = slint::Timer::default();
604607
timer.start(
@@ -638,18 +641,32 @@ mod donation_handler {
638641
if let Some(ref tok) = token {
639642
let username = window.get_session_username().to_string();
640643
let fund_id = window.get_session_fund_id();
644+
let fund_name = window.get_session_fund_name().to_string();
641645
let tok = tok.clone();
642646
let photos_dir = photos_dir.clone();
647+
let stats_db_path = stats_db_path.clone();
643648
slint::spawn_local(async move {
644649
match donation::send_donation(&tok, fund_id, &username, amount)
645650
.await
646651
{
647652
Ok(_) => {
648653
sound::play_yippee();
649654
info!("✅ Auto-approved donation sent successfully!");
655+
let timestamp = donation_log::now_timestamp();
650656
if username != "anon" {
651-
camera::capture_donation_photo(&photos_dir, &username);
657+
camera::capture_donation_photo(
658+
&photos_dir,
659+
&username,
660+
timestamp,
661+
);
652662
}
663+
donation_log::record(
664+
&stats_db_path,
665+
timestamp,
666+
&username,
667+
amount,
668+
&fund_name,
669+
);
653670
}
654671
Err(e) => {
655672
error!("❌ Auto-approve: failed to send donation: {}", e)
@@ -687,6 +704,8 @@ mod donation_handler {
687704
let cctalk_tx = cctalk_tx.clone();
688705
let token = config.token.clone();
689706
let photos_dir = config.photos_dir.clone();
707+
let stats_db_path = config.stats_db_path.clone();
708+
let weak = app.as_weak();
690709
move |username, fund_id, amount| {
691710
info!(
692711
"💰 Processing donation: {} AMD from {} to fund {}",
@@ -711,15 +730,32 @@ mod donation_handler {
711730
let token = token.clone();
712731
let username_str = username.to_string();
713732
let photos_dir = photos_dir.clone();
733+
let stats_db_path = stats_db_path.clone();
734+
let fund_name = weak
735+
.upgrade()
736+
.map(|w| w.get_session_fund_name().to_string())
737+
.unwrap_or_default();
714738
slint::spawn_local(async move {
715739
match donation::send_donation(&token, fund_id, &username_str, amount).await
716740
{
717741
Ok(_) => {
718742
sound::play_yippee();
719743
info!("✅ Donation sent successfully!");
744+
let timestamp = donation_log::now_timestamp();
720745
if username_str != "anon" {
721-
camera::capture_donation_photo(&photos_dir, &username_str);
746+
camera::capture_donation_photo(
747+
&photos_dir,
748+
&username_str,
749+
timestamp,
750+
);
722751
}
752+
donation_log::record(
753+
&stats_db_path,
754+
timestamp,
755+
&username_str,
756+
amount,
757+
&fund_name,
758+
);
723759
}
724760
Err(e) => error!("❌ Failed to send donation: {}", e),
725761
}
@@ -736,6 +772,7 @@ mod donation_handler {
736772
let cashcode_tx_enter = cashcode_tx.clone();
737773
let token_enter = config.token.clone();
738774
let photos_dir_enter = config.photos_dir.clone();
775+
let stats_db_path_enter = config.stats_db_path.clone();
739776
let timer_enter = inactivity_timer.clone();
740777
let ticker_enter = countdown_ticker.clone();
741778
app.on_enter_insert_money(move || {
@@ -753,6 +790,7 @@ mod donation_handler {
753790
cashcode_tx_enter.clone(),
754791
token_enter.clone(),
755792
photos_dir_enter.clone(),
793+
stats_db_path_enter.clone(),
756794
);
757795
*timer_enter.borrow_mut() = Some(timer);
758796
// Countdown ticker (1-second decrement)
@@ -778,6 +816,7 @@ mod donation_handler {
778816
let cashcode_tx_activity = cashcode_tx.clone();
779817
let token_activity = config.token.clone();
780818
let photos_dir_activity = config.photos_dir.clone();
819+
let stats_db_path_activity = config.stats_db_path.clone();
781820
let timer_activity = inactivity_timer.clone();
782821
let ticker_activity = countdown_ticker.clone();
783822
app.on_activity_on_insert_money(move || {
@@ -792,6 +831,7 @@ mod donation_handler {
792831
cashcode_tx_activity.clone(),
793832
token_activity.clone(),
794833
photos_dir_activity.clone(),
834+
stats_db_path_activity.clone(),
795835
);
796836
*timer_activity.borrow_mut() = Some(timer);
797837
// Replace countdown ticker
@@ -867,6 +907,82 @@ mod donation_handler {
867907
}
868908
}
869909

910+
mod logs_handler {
911+
use super::*;
912+
use slint::{Image, ModelRc, VecModel};
913+
914+
/// How many past donations to show on the wall. Each entry with a photo
915+
/// loads that photo into memory at full resolution, so this is kept modest.
916+
const LOG_LIMIT: i64 = 24;
917+
918+
fn format_relative_time(timestamp: u64) -> String {
919+
let diff = donation_log::now_timestamp().saturating_sub(timestamp);
920+
if diff < 60 {
921+
"just now".to_string()
922+
} else if diff < 3600 {
923+
format!("{}m ago", diff / 60)
924+
} else if diff < 86400 {
925+
format!("{}h ago", diff / 3600)
926+
} else {
927+
format!("{}d ago", diff / 86400)
928+
}
929+
}
930+
931+
pub fn init(app: &MainWindow, config: &Config) {
932+
let stats_db_path = config.stats_db_path.clone();
933+
let photos_dir = config.photos_dir.clone();
934+
let weak = app.as_weak();
935+
936+
app.on_fetch_logs(move || {
937+
let stats_db_path = stats_db_path.clone();
938+
let photos_dir = photos_dir.clone();
939+
let weak = weak.clone();
940+
941+
thread::spawn(move || {
942+
// The DB read is the slow part, so it happens off the UI thread.
943+
// `slint::Image` isn't `Send`, though, so it can't be built here —
944+
// loading each photo has to happen after we hop back to the UI thread.
945+
let entries = match donation_log::fetch_recent(&stats_db_path, LOG_LIMIT) {
946+
Ok(entries) => entries,
947+
Err(e) => {
948+
error!("Failed to fetch donation log: {}", e);
949+
Vec::new()
950+
}
951+
};
952+
953+
let _ = slint::invoke_from_event_loop(move || {
954+
let Some(window) = weak.upgrade() else {
955+
return;
956+
};
957+
let items: Vec<DonationLogItem> = entries
958+
.into_iter()
959+
.map(|entry| {
960+
let is_anon = entry.username == "anon";
961+
let photo = if is_anon {
962+
None
963+
} else {
964+
let path = std::path::Path::new(&photos_dir)
965+
.join(camera::photo_filename(entry.timestamp, &entry.username));
966+
Image::load_from_path(&path).ok()
967+
};
968+
DonationLogItem {
969+
username: entry.username.into(),
970+
amount: entry.amount,
971+
fund_name: entry.fund_name.into(),
972+
when: format_relative_time(entry.timestamp).into(),
973+
is_anon,
974+
has_photo: photo.is_some(),
975+
photo: photo.unwrap_or_default(),
976+
}
977+
})
978+
.collect();
979+
window.set_donation_logs(ModelRc::new(VecModel::from(items)));
980+
});
981+
});
982+
});
983+
}
984+
}
985+
870986
mod diagnostics_handler {
871987
use super::*;
872988
use slint::{ModelRc, Timer, TimerMode, VecModel};

0 commit comments

Comments
 (0)