financer 0.1.0

A Finance tracker in your terminal
use ratatui::{
    buffer::Buffer,
    crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind},
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Style, Stylize},
    symbols::border,
    text::{Line, Text},
    widgets::{Block, Clear, Paragraph, Widget},
    DefaultTerminal, Frame,
};
use serde::{Deserialize, Serialize};
use std::io;
use std::path::Path;
use tui_input::Input;

const DATA_FILE: &str = "transactions.json";

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InputField {
    Title,
    Amount,
    Category,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
    title: String,
    amount: f64,
    category: String,
}

impl Default for InputField {
    fn default() -> Self {
        InputField::Title
    }
}

pub struct App {
    title_input: Input,
    amount_input: Input,
    category_input: Input,
    active_field: InputField,
    transactions: Vec<Transaction>,
    show_transactions: bool,
    exit: bool,
}

impl Default for App {
    fn default() -> Self {
        let transactions = Self::load_transactions();
        Self {
            title_input: Input::default(),
            amount_input: Input::default(),
            category_input: Input::default(),
            active_field: InputField::default(),
            transactions,
            show_transactions: false,
            exit: false,
        }
    }
}

impl App {
    fn load_transactions() -> Vec<Transaction> {
        if Path::new(DATA_FILE).exists() {
            match std::fs::read_to_string(DATA_FILE) {
                Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
                Err(_) => Vec::new(),
            }
        } else {
            Vec::new()
        }
    }

    fn save_transactions(&self) -> io::Result<()> {
        let json = serde_json::to_string_pretty(&self.transactions)
            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
        std::fs::write(DATA_FILE, json)
    }

    pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
        while !self.exit {
            terminal.draw(|frame| self.draw(frame))?;
            self.handle_events()?;
        }
        Ok(())
    }

    fn draw(&self, frame: &mut Frame) {
        frame.render_widget(self, frame.area());
    }

    fn handle_events(&mut self) -> io::Result<()> {
        if let Event::Key(key) = ratatui::crossterm::event::read()? {
            if key.kind == KeyEventKind::Press {
                if self.show_transactions {
                    match key.code {
                        KeyCode::Esc => self.show_transactions = false,
                        _ => {}
                    }
                } else {
                    match key.code {
                        KeyCode::Char('q') => self.exit(),
                        KeyCode::Tab => self.next_field(),
                        KeyCode::BackTab => self.prev_field(),
                        KeyCode::Enter => self.save_transaction(),
                        KeyCode::Char('l') => self.show_transactions = true,
                        _ => self.handle_input(key),
                    }
                }
            }
        }
        Ok(())
    }

    fn handle_input(&mut self, key: KeyEvent) {
        use tui_input::backend::crossterm::EventHandler;

        match self.active_field {
            InputField::Title => {
                self.title_input.handle_event(&Event::Key(key));
            }
            InputField::Amount => {
                self.amount_input.handle_event(&Event::Key(key));
            }
            InputField::Category => {
                self.category_input.handle_event(&Event::Key(key));
            }
        };
    }

    fn next_field(&mut self) {
        self.active_field = match self.active_field {
            InputField::Title => InputField::Amount,
            InputField::Amount => InputField::Category,
            InputField::Category => InputField::Title,
        };
    }

    fn prev_field(&mut self) {
        self.active_field = match self.active_field {
            InputField::Title => InputField::Category,
            InputField::Amount => InputField::Title,
            InputField::Category => InputField::Amount,
        };
    }

    fn save_transaction(&mut self) {
        let title = self.title_input.value().to_string();
        let amount_str = self.amount_input.value();
        let category = self.category_input.value().to_string();

        if title.is_empty() || amount_str.is_empty() {
            return;
        }

        if let Ok(amount) = amount_str.parse::<f64>() {
            let transaction = Transaction {
                title: title.clone(),
                amount,
                category,
            };
            self.transactions.push(transaction);

            let _ = self.save_transactions();

            self.title_input = Input::default();
            self.amount_input = Input::default();
            self.category_input = Input::default();
            self.active_field = InputField::Title;
        }
    }

    fn exit(&mut self) {
        self.exit = true;
    }

    fn get_input_widget(&self, field: InputField) -> Paragraph<'_> {
        let is_active = self.active_field == field;
        let (label, input) = match field {
            InputField::Title => ("Title", &self.title_input),
            InputField::Amount => ("Amount", &self.amount_input),
            InputField::Category => ("Category", &self.category_input),
        };

        let border_color = if is_active {
            Color::Yellow
        } else {
            Color::Gray
        };
        let title_style = if is_active {
            Style::default().fg(Color::Yellow).bold()
        } else {
            Style::default().fg(Color::White)
        };

        Paragraph::new(input.value())
            .block(
                Block::bordered()
                    .title(label)
                    .title_style(title_style)
                    .border_style(Style::default().fg(border_color)),
            )
            .style(Style::default().fg(Color::White))
    }

    fn render_transactions(&self, area: Rect, buf: &mut Buffer) {
        Clear.render(area, buf);

        let block = Block::bordered()
            .title("Transactions")
            .title_alignment(Alignment::Center)
            .border_style(Style::default().fg(Color::Cyan));

        let inner_area = block.inner(area);
        block.render(area, buf);

        if self.transactions.is_empty() {
            Paragraph::new("No transactions yet. Press Esc to close.")
                .alignment(Alignment::Center)
                .render(inner_area, buf);
            return;
        }

        let mut lines: Vec<Line> = vec![
            Line::from(vec![
                "Title".bold(),
                " | ".into(),
                "Amount".bold(),
                " | ".into(),
                "Category".bold(),
            ]),
            Line::from("-".repeat(50)),
        ];

        for transaction in &self.transactions {
            let amount_str = format!("₹{:.2}", transaction.amount);
            lines.push(Line::from(vec![
                transaction.title.clone().into(),
                " | ".into(),
                amount_str.yellow(),
                " | ".into(),
                transaction.category.clone().green(),
            ]));
        }

        lines.push(Line::from(""));
        lines.push(Line::from("Press Esc to close".gray()));

        Paragraph::new(Text::from(lines)).render(inner_area, buf);
    }
}

impl Widget for &App {
    fn render(self, area: Rect, buf: &mut Buffer) {
        if self.show_transactions {
            let popup_area = centered_rect(80, 80, area);
            self.render_transactions(popup_area, buf);
            return;
        }

        let main_layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Length(12),
                Constraint::Min(0),
            ])
            .split(area);

        let title = Line::from("Financer".bold().fg(Color::Red));
        let header = Paragraph::new(title).alignment(Alignment::Center);
        header.render(main_layout[0], buf);

        let form_layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Length(3),
                Constraint::Length(3),
            ])
            .margin(1)
            .split(main_layout[1]);

        let block = Block::bordered()
            .title("Transaction Entry")
            .title_alignment(Alignment::Center)
            .border_set(border::HEAVY_DOUBLE_DASHED)
            .border_style(Style::default().fg(Color::Green))
            .style(Style::default().bg(Color::Black));

        block.render(main_layout[1], buf);

        self.get_input_widget(InputField::Title)
            .render(form_layout[0], buf);
        self.get_input_widget(InputField::Amount)
            .render(form_layout[1], buf);
        self.get_input_widget(InputField::Category)
            .render(form_layout[2], buf);

        let instructions = Line::from(vec![
            " Enter ".into(),
            "Save".green().bold(),
            " Tab ".into(),
            "Next".blue().bold(),
            " L ".into(),
            "List".cyan().bold(),
            " Q ".into(),
            "Quit".red().bold(),
        ]);

        Paragraph::new(instructions)
            .alignment(Alignment::Center)
            .render(main_layout[2], buf);
    }
}

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);

    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]
}