Compare commits

...

10 Commits

Author SHA1 Message Date
thatscringebro
df4fd0202d trx as list => to change to table 2025-12-18 15:57:49 -05:00
thatscringebro
2395d72c7e fixed list 2025-12-17 19:13:54 -05:00
thatscringebro
dd6b6f7b2c better ac list 2025-12-17 13:49:31 -05:00
thatscringebro
33fb39e732 début du ui 2025-12-17 07:55:40 -05:00
thatscringebro
55dfd16cd2 started using ratatui 2025-12-10 20:54:25 -05:00
thatscringebro
24ac0a0fbe grand total 2025-12-10 14:46:45 -05:00
thatscringebro
e8b3eae889 account total 2025-12-10 12:58:45 -05:00
thatscringebro
d512dedd21 adding and listing transactions baby 2025-12-10 09:10:10 -05:00
thatscringebro
4c4967d50a cli interface for adding/listing accounts and t_types 2025-11-12 11:14:37 -05:00
thatscringebro
83281e9199 finished making fn for entities 2025-11-12 11:14:07 -05:00
7 changed files with 684 additions and 22 deletions

View File

@@ -5,4 +5,5 @@ edition = "2024"
[dependencies] [dependencies]
chrono = "0.4.42" chrono = "0.4.42"
ratatui = "0.29.0"
sqlite = "0.37.0" sqlite = "0.37.0"

128
src/app.rs Normal file
View File

@@ -0,0 +1,128 @@
use ratatui::widgets::ListState;
use sqlite::Connection;
use crate::{
data_layer,
entities::{Account, AccountType, Transaction},
};
pub enum CurrentScreen {
Main,
Exiting,
}
pub enum CurrentWidget {
AccountList,
TrxInfo,
TrxList,
}
pub struct AccountList {
accounts: Vec<Account>,
pub state: ListState,
}
impl AccountList {
fn new() -> AccountList {
let mut list_state = ListState::default();
list_state.select_first();
return AccountList {
accounts: Vec::new(),
state: list_state,
};
}
fn get_accounts(&self, con: &Connection) -> Vec<Account> {
if self.accounts.iter().count() == 0 {
return data_layer::get_accounts(con);
}
return self.accounts.clone();
}
}
pub struct TrxList {
trx: Vec<Transaction>,
pub state: ListState,
}
impl TrxList {
fn new() -> TrxList {
let mut list_state = ListState::default();
list_state.select_first();
return TrxList {
trx: Vec::new(),
state: list_state,
};
}
fn get_trx(&self, con: &Connection, app: &App) -> Vec<Transaction> {
if self.trx.iter().count() == 0 {
if let Some(i) = app.acc_list.state.selected() {
return data_layer::get_account_transactions(
con,
app.get_list_accounts()[i].get_id(),
);
}
return Vec::new();
}
return self.trx.clone();
}
}
pub struct App {
pub current_screen: CurrentScreen,
pub current_widget: CurrentWidget,
pub acc_list: AccountList,
pub trx_list: TrxList,
pub connection: Connection,
exit: bool,
}
impl App {
pub fn new() -> App {
let con = match Connection::open("ft_rs.db") {
Ok(con) => con,
Err(e) => {
eprintln!("Error opening database: {}", e);
panic!("stopping");
}
};
return App {
current_screen: CurrentScreen::Main,
current_widget: CurrentWidget::AccountList,
acc_list: AccountList::new(),
trx_list: TrxList::new(),
connection: con,
exit: false,
};
}
pub fn get_list_accounts(&self) -> Vec<Account> {
let mut accounts = self.acc_list.get_accounts(&self.connection);
let all = Account::new(0, "All".to_string(), AccountType::Cash);
accounts.insert(0, all);
return accounts;
}
pub fn first_ac(&mut self) {
self.acc_list.state.select_first();
}
pub fn next_ac(&mut self) {
self.acc_list.state.select_next();
}
pub fn previous_ac(&mut self) {
self.acc_list.state.select_previous();
}
pub fn get_list_trx(&self) -> Vec<Transaction> {
let accounts = &self.trx_list.get_trx(&self.connection, self);
return accounts.to_vec();
}
pub fn first_tr(&mut self) {
self.trx_list.state.select_first();
}
pub fn next_tr(&mut self) {
self.trx_list.state.select_next();
}
pub fn previous_tr(&mut self) {
self.trx_list.state.select_previous();
}
}

View File

@@ -1,4 +1,5 @@
use crate::entities::*; use crate::entities::*;
use chrono::DateTime;
use sqlite::{Connection, State}; use sqlite::{Connection, State};
pub fn setup(con: &Connection) { pub fn setup(con: &Connection) {
@@ -20,7 +21,7 @@ pub fn setup(con: &Connection) {
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
type_id INTEGER NOT NULL, type_id INTEGER NOT NULL,
amount FLOAT NOT NULL, amount FLOAT NOT NULL,
date DATE NOT NULL, date INTEGER NOT NULL,
description TEXT, description TEXT,
FOREIGN KEY(account_id) REFERENCES Accounts(id), FOREIGN KEY(account_id) REFERENCES Accounts(id),
FOREIGN KEY(type_id) REFERENCES TransactionTypes(id) FOREIGN KEY(type_id) REFERENCES TransactionTypes(id)
@@ -76,6 +77,25 @@ pub fn get_account(con: &Connection, id: i64) -> Account {
} }
} }
pub fn get_account_total(id: i64, con: &Connection) -> f64 {
let mut query = "SELECT SUM(amount) as total FROM Transactions".to_owned();
if id != 0 {
query.push_str(" WHERE account_id = ?")
}
let mut statement = con.prepare(query).unwrap();
if id != 0 {
statement.bind((1, id)).unwrap();
}
if let Ok(State::Row) = statement.next() {
return statement.read::<f64, _>("total").unwrap();
} else {
return 0.0;
}
}
pub fn get_accounts(con: &Connection) -> Vec<Account> { pub fn get_accounts(con: &Connection) -> Vec<Account> {
let query = "SELECT * FROM Accounts"; let query = "SELECT * FROM Accounts";
let mut statement = con.prepare(query).unwrap(); let mut statement = con.prepare(query).unwrap();
@@ -150,3 +170,110 @@ pub fn get_transaction_types(con: &Connection) -> Vec<TransactionType> {
return vec; return vec;
} }
pub fn upsert_transaction(con: &Connection, tr: Transaction) -> Transaction {
let query;
if tr.get_id() == 0 {
query = "INSERT INTO Transactions
(account_id, type_id, amount, date, description)
VALUES (?, ?, ?, ?, ?)
RETURNING id;";
} else {
query = "UPDATE Transactions
SET account_id = ?, type_id = ?, amount = ?, date = ?, description = ?
WHERE id = ? RETURNING id;";
}
let mut statement = con.prepare(query).unwrap();
statement.bind((1, tr.get_account().get_id())).unwrap();
statement.bind((2, tr.get_type().get_id())).unwrap();
statement.bind((3, tr.get_amount())).unwrap();
statement.bind((4, tr.get_date().timestamp())).unwrap();
statement.bind((5, &tr.get_desc() as &str)).unwrap();
if tr.get_id() != 0 {
statement.bind((6, tr.get_id())).unwrap();
}
let id;
if let Ok(State::Row) = statement.next() {
id = statement.read::<i64, _>("id").unwrap();
} else {
id = 0;
}
return Transaction::new(
id,
tr.get_account().get_id(),
tr.get_amount(),
tr.get_date(),
tr.get_desc(),
tr.get_type().get_id(),
con,
);
}
pub fn get_transaction(con: &Connection, id: i64) -> Transaction {
let query = "SELECT * FROM Transactions WHERE id = ?";
let mut statement = con.prepare(query).unwrap();
statement.bind((1, id)).unwrap();
if let Ok(State::Row) = statement.next() {
return Transaction::new(
statement.read::<i64, _>("id").unwrap(),
statement.read::<i64, _>("account_id").unwrap(),
statement.read::<f64, _>("amount").unwrap(),
DateTime::from_timestamp(statement.read::<i64, _>("date").unwrap(), 0).unwrap(),
statement.read::<String, _>("description").unwrap(),
statement.read::<i64, _>("type_id").unwrap(),
con,
);
} else {
return Transaction::new_empty();
}
}
pub fn get_account_transactions(con: &Connection, ac_id: i64) -> Vec<Transaction> {
if ac_id == 0 {
return get_transactions(con);
}
let query = "SELECT * FROM Transactions WHERE account_id = ?";
let mut statement = con.prepare(query).unwrap();
statement.bind((1, ac_id)).unwrap();
let mut vec = Vec::<Transaction>::new();
while let Ok(State::Row) = statement.next() {
vec.push(Transaction::new(
statement.read::<i64, _>("id").unwrap(),
statement.read::<i64, _>("account_id").unwrap(),
statement.read::<f64, _>("amount").unwrap(),
DateTime::from_timestamp(statement.read::<i64, _>("date").unwrap(), 0).unwrap(),
statement.read::<String, _>("description").unwrap(),
statement.read::<i64, _>("type_id").unwrap(),
con,
));
}
return vec;
}
pub fn get_transactions(con: &Connection) -> Vec<Transaction> {
let query = "SELECT * FROM TransactionTypes";
let mut statement = con.prepare(query).unwrap();
let mut vec = Vec::<Transaction>::new();
while let Ok(State::Row) = statement.next() {
vec.push(Transaction::new(
statement.read::<i64, _>("id").unwrap(),
statement.read::<i64, _>("account_id").unwrap(),
statement.read::<f64, _>("amount").unwrap(),
DateTime::from_timestamp(statement.read::<i64, _>("date").unwrap(), 0).unwrap(),
statement.read::<String, _>("description").unwrap(),
statement.read::<i64, _>("type_id").unwrap(),
con,
));
}
return vec;
}

View File

@@ -1,4 +1,10 @@
use std::convert::TryFrom; use std::convert::TryFrom;
use sqlite::Connection;
use crate::data_layer;
#[derive(Clone)]
pub struct Account { pub struct Account {
id: i64, id: i64,
name: String, name: String,
@@ -22,6 +28,9 @@ impl Account {
pub fn get_ac_type(&self) -> AccountType { pub fn get_ac_type(&self) -> AccountType {
return AccountType::try_from(self.ac_type as i64).unwrap(); return AccountType::try_from(self.ac_type as i64).unwrap();
} }
pub fn get_total(&self, con: &Connection) -> f64 {
return data_layer::get_account_total(self.id, con);
}
} }
#[derive(Copy, Clone)] #[derive(Copy, Clone)]

View File

@@ -2,6 +2,7 @@ use crate::{data_layer, entities::Account};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlite::Connection; use sqlite::Connection;
#[derive(Clone)]
pub struct Transaction { pub struct Transaction {
id: i64, id: i64,
account_id: i64, account_id: i64,
@@ -31,11 +32,24 @@ impl Transaction {
date: date, date: date,
description: desc, description: desc,
type_id: type_id, type_id: type_id,
account: data_layer::get_account(con, id), account: data_layer::get_account(con, ac_id),
tr_type: data_layer::get_transaction_type(con, type_id), tr_type: data_layer::get_transaction_type(con, type_id),
} }
} }
pub fn new_empty() -> Self {
Transaction {
id: 0,
account_id: 0,
amount: 0.0,
date: DateTime::from_timestamp(0, 0).unwrap(),
description: "".to_string(),
type_id: 0,
account: Account::new(0, "".to_string(), super::AccountType::Cash),
tr_type: TransactionType::new(0, "".to_string()),
}
}
pub fn get_id(&self) -> i64 { pub fn get_id(&self) -> i64 {
return self.id; return self.id;
} }
@@ -49,18 +63,19 @@ impl Transaction {
} }
pub fn get_desc(&self) -> String { pub fn get_desc(&self) -> String {
return self.description; return self.description.clone();
} }
pub fn get_account(&self) -> Account { pub fn get_account(&self) -> Account {
return self.account; return self.account.clone();
} }
pub fn get_type(&self) -> TransactionType { pub fn get_type(&self) -> TransactionType {
return self.tr_type; return self.tr_type.clone();
} }
} }
#[derive(Clone)]
pub struct TransactionType { pub struct TransactionType {
id: i64, id: i64,
description: String, description: String,

View File

@@ -1,25 +1,243 @@
mod app;
mod data_layer; mod data_layer;
mod entities; mod entities;
use sqlite::Connection; mod ui;
use crate::{
use crate::entities::Account; app::{App, CurrentScreen},
ui::ui,
fn main() {
println!("Hello, world!");
let connection = match Connection::open("ft_rs.db") {
Ok(con) => con,
Err(e) => {
eprintln!("Error opening database: {}", e);
return;
}
}; };
use app::CurrentWidget;
use ratatui::{
Terminal,
crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
},
prelude::Backend,
};
use std::{error::Error, io};
data_layer::setup(&connection); fn main() -> Result<(), Box<dyn Error>> {
let mut account = Account::new(0, "test".to_string(), entities::AccountType::Cash); enable_raw_mode()?;
account = data_layer::upsert_account(&connection, account); let mut stderr = io::stderr();
let accounts = data_layer::get_accounts(&connection); execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
for ac in accounts.iter() {
println!("name: {}", ac.get_name()) let mut terminal = ratatui::init();
let mut app = App::new();
let res = run_app(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
return Ok(());
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<bool> {
loop {
terminal.draw(|f| ui(f, app))?;
// S'occupe des keyevents
if let Event::Key(key) = event::read()? {
if key.kind == event::KeyEventKind::Release {
continue;
}
match app.current_screen {
CurrentScreen::Main => match key.code {
KeyCode::Char('q') => {
app.current_screen = CurrentScreen::Exiting;
}
KeyCode::Right => {
app.current_widget = CurrentWidget::TrxList;
}
KeyCode::Left => {
app.current_widget = CurrentWidget::AccountList;
}
KeyCode::Char('j') => {
match app.current_widget {
CurrentWidget::AccountList => {
app.next_ac();
}
CurrentWidget::TrxList => {
//app.next_trx();
}
_ => {}
} }
} }
KeyCode::Char('k') => {
match app.current_widget {
CurrentWidget::AccountList => {
app.previous_ac();
}
CurrentWidget::TrxList => {
//app.previous_trx();
}
_ => {}
}
}
_ => {}
},
CurrentScreen::Exiting => match key.code {
KeyCode::Char('y') => {
return Ok(true);
}
_ => {
app.current_screen = CurrentScreen::Main;
}
},
}
}
}
}
// fn main() {
// let connection = match Connection::open("ft_rs.db") {
// Ok(con) => con,
// Err(e) => {
// eprintln!("Error opening database: {}", e);
// return;
// }
// };
// data_layer::setup(&connection);
// loop {
// println!("Please choose an option:");
// println!("1. List accounts");
// println!("2. Add an account");
// println!("3. List transaction types");
// println!("4. Add a transaction type");
// println!("5. List transactions");
// println!("6. Add a transaction");
// println!("0. Exit");
// let mut choice = String::new();
// io::stdin()
// .read_line(&mut choice)
// .expect("Failed to read line");
// let choice = choice.trim();
// match choice {
// "1" => {
// let accounts = data_layer::get_accounts)(&connection);
// let mut total = 0.0;
// for ac in accounts.iter() {
// let ac_total = ac.get_total(&connection);
// println!(
// "Id: {}, Name: {}, Total: {}",
// ac.get_id(),
// ac.get_name(),
// ac_total
// );
// total += ac_total;
// }
// println!("Total: {}", total);
// }
// "2" => {
// println!("Please enter the account name:");
// let mut ac_name = String::new();
// io::stdin()
// .read_line(&mut ac_name)
// .expect("Failed to read line");
// let ac_name = ac_name.trim();
// let new_ac = Account::new(0, ac_name.to_string(), entities::AccountType::Cash);
// data_layer::upsert_account(&connection, new_ac);
// }
// "3" => {
// let types = data_layer::get_transaction_types(&connection);
// for t in types.iter() {
// println!("Name: {}", t.get_description());
// }
// }
// "4" => {
// println!("Please enter the transaction type:");
// let mut desc = String::new();
// io::stdin()
// .read_line(&mut desc)
// .expect("Failed to read line");
// let desc = desc.trim();
// let tr_type = TransactionType::new(0, desc.to_string());
// data_layer::upsert_transaction_type(&connection, tr_type);
// }
// "5" => {
// println!("Please enter the account id:");
// let mut ac_id_str = String::new();
// io::stdin()
// .read_line(&mut ac_id_str)
// .expect("Failed to read line");
// ac_id_str = ac_id_str.trim().to_string();
// let ac_id = ac_id_str.parse::<i64>().unwrap();
// let trx: Vec<entities::Transaction> =
// data_layer::get_account_transactions(&connection, ac_id);
// // .sort_by(|a, b| b.get_date().cmp(&a.get_date()))
// // .try_into()
// // .unwrap();
// for t in trx.iter() {
// println!(
// "Date: {}, Type: {}, Description: {}, Amount: {}$",
// Local.from_utc_datetime(&t.get_date().naive_local()),
// t.get_type().get_description(),
// t.get_desc(),
// t.get_amount()
// );
// }
// }
// "6" => {
// println!("Please enter the account id:");
// let mut ac_id_str = String::new();
// io::stdin()
// .read_line(&mut ac_id_str)
// .expect("Failed to read line");
// ac_id_str = ac_id_str.trim().to_string();
// let ac_id = ac_id_str.parse::<i64>().unwrap();
// println!("Please enter the transaction type id:");
// let mut tr_type_id_str = String::new();
// io::stdin()
// .read_line(&mut tr_type_id_str)
// .expect("Failed to read line");
// tr_type_id_str = tr_type_id_str.trim().to_string();
// let type_id = tr_type_id_str.parse::<i64>().unwrap();
// println!("Please enter the amount:");
// let mut amnt_str = String::new();
// io::stdin()
// .read_line(&mut amnt_str)
// .expect("Failed to read line");
// amnt_str = amnt_str.trim().to_string();
// let amount = amnt_str.parse::<f64>().unwrap();
// println!("Please enter the description:");
// let mut desc = String::new();
// io::stdin()
// .read_line(&mut desc)
// .expect("Failed to read line");
// desc = desc.trim().to_string();
// let tr = Transaction::new(
// 0,
// ac_id,
// amount,
// chrono::offset::Utc::now(),
// desc,
// type_id,
// &connection,
// );
// data_layer::upsert_transaction(&connection, tr);
// }
// "0" => {
// println!("Exiting...");
// break; // Exit the loop
// }
// _ => {
// println!("Invalid choice.");
// }
// }
// }
// }

164
src/ui.rs Normal file
View File

@@ -0,0 +1,164 @@
use crate::{
app::{App, CurrentScreen, CurrentWidget},
entities::{Account, Transaction},
};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{
Color, Modifier, Style, Stylize,
palette::tailwind::{BLUE, GREEN, SLATE},
},
text::{Line, Text},
widgets::{
Block, Borders, Clear, HighlightSpacing, List, ListItem, Paragraph, StatefulWidget, Widget,
Wrap,
},
};
const NORMAL_ROW_BG: Color = SLATE.c950;
const ALT_ROW_BG_COLOR: Color = SLATE.c900;
const TEXT_FG_COLOR: Color = SLATE.c200;
const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD);
pub fn ui(frame: &mut Frame, app: &mut App) {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(20), Constraint::Percentage(80)])
.split(frame.area());
let right_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Percentage(10), Constraint::Percentage(90)])
.split(layout[1]);
let mut ac_block = Block::default()
.title("Accounts")
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray));
let mut info_block = Block::default()
.title("Account info")
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray));
let mut trx_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray));
let active_style = Style::default();
match app.current_widget {
CurrentWidget::AccountList => ac_block = ac_block.border_style(active_style),
CurrentWidget::TrxInfo => info_block = info_block.border_style(active_style),
CurrentWidget::TrxList => trx_block = trx_block.border_style(active_style),
};
let ac_items: Vec<ListItem> = app
.get_list_accounts()
.iter()
.enumerate()
.map(|(i, acc)| {
let color = alternate_colors(i);
ListItem::from(acc).bg(color)
})
.collect();
let list = List::new(ac_items)
.block(ac_block)
.highlight_style(SELECTED_STYLE)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(list, layout[0], frame.buffer_mut(), &mut app.acc_list.state);
let info = if let Some(i) = app.acc_list.state.selected() {
format!(
"Total: {}",
app.get_list_accounts()[i]
.get_total(&app.connection)
.to_string()
)
} else {
"No account selected...".to_string()
};
frame.render_widget(Paragraph::new(info).block(info_block), right_layout[0]);
let trx_items: Vec<ListItem> = app
.get_list_trx()
.iter()
.enumerate()
.map(|(i, tr)| {
let color = alternate_colors(i);
ListItem::from(tr).bg(color)
})
.collect();
let trx_list = List::new(trx_items)
.block(trx_block)
.highlight_style(SELECTED_STYLE)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(
trx_list,
right_layout[1],
frame.buffer_mut(),
&mut app.trx_list.state,
);
if let CurrentScreen::Exiting = app.current_screen {
let popup = Block::default()
.title("Exiting program")
.borders(Borders::all())
.style(Style::default());
let exit_text = Text::styled(
"Are you sure you want to close the program? (y/n)",
Style::default().fg(Color::Red),
);
let exit_paragraph = Paragraph::new(exit_text)
.block(popup)
.wrap(Wrap { trim: false });
let area = centered_rect(50, 20, frame.area());
frame.render_widget(exit_paragraph, area);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
return Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1];
}
const fn alternate_colors(i: usize) -> Color {
if i % 2 == 0 {
NORMAL_ROW_BG
} else {
ALT_ROW_BG_COLOR
}
}
impl From<&Account> for ListItem<'_> {
fn from(value: &Account) -> Self {
let line = Line::styled(value.get_name(), TEXT_FG_COLOR);
ListItem::new(line)
}
}
impl From<&Transaction> for ListItem<'_> {
fn from(value: &Transaction) -> Self {
let line = Line::styled(value.get_desc(), TEXT_FG_COLOR);
ListItem::new(line)
}
}