envelope_cli/tui/dialogs/
adjustment.rs1use ratatui::{
6 layout::{Constraint, Direction, Layout},
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Clear, Paragraph},
10 Frame,
11};
12
13use crate::models::{CategoryId, Money};
14use crate::tui::app::App;
15use crate::tui::layout::centered_rect_fixed;
16
17#[derive(Debug, Clone, Default)]
19pub struct AdjustmentDialogState {
20 pub adjustment_amount: Money,
22 pub category_input: String,
24 pub selected_category: Option<CategoryId>,
26 pub categories: Vec<(CategoryId, String)>,
28 pub selected_index: usize,
30 pub selecting_category: bool,
32}
33
34impl AdjustmentDialogState {
35 pub fn new(adjustment_amount: Money) -> Self {
36 Self {
37 adjustment_amount,
38 category_input: String::new(),
39 selected_category: None,
40 categories: Vec::new(),
41 selected_index: 0,
42 selecting_category: false,
43 }
44 }
45
46 pub fn load_categories(&mut self, categories: Vec<(CategoryId, String)>) {
48 self.categories = categories;
49 }
50
51 pub fn move_up(&mut self) {
53 if self.selecting_category && self.selected_index > 0 {
54 self.selected_index -= 1;
55 }
56 }
57
58 pub fn move_down(&mut self) {
60 if self.selecting_category && self.selected_index < self.categories.len().saturating_sub(1)
61 {
62 self.selected_index += 1;
63 }
64 }
65
66 pub fn filtered_categories(&self) -> Vec<&(CategoryId, String)> {
68 if self.category_input.is_empty() {
69 self.categories.iter().collect()
70 } else {
71 let search = self.category_input.to_lowercase();
72 self.categories
73 .iter()
74 .filter(|(_, name)| name.to_lowercase().contains(&search))
75 .collect()
76 }
77 }
78
79 pub fn select_current(&mut self) {
81 let selection: Option<(CategoryId, String)> = {
83 let filtered = self.filtered_categories();
84 filtered
85 .get(self.selected_index)
86 .map(|(id, name)| (*id, name.clone()))
87 };
88
89 if let Some((id, name)) = selection {
90 self.selected_category = Some(id);
91 self.category_input = name;
92 self.selecting_category = false;
93 }
94 }
95}
96
97pub fn render(frame: &mut Frame, app: &App) {
99 let area = centered_rect_fixed(60, 14, frame.area());
100
101 frame.render_widget(Clear, area);
103
104 let state = &app.adjustment_dialog_state;
105
106 let block = Block::default()
107 .title(" Create Adjustment Transaction ")
108 .title_style(
109 Style::default()
110 .fg(Color::Yellow)
111 .add_modifier(Modifier::BOLD),
112 )
113 .borders(Borders::ALL)
114 .border_style(Style::default().fg(Color::Yellow));
115
116 let inner = block.inner(area);
117 frame.render_widget(block, area);
118
119 let chunks = Layout::default()
120 .direction(Direction::Vertical)
121 .constraints([
122 Constraint::Length(1), Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), Constraint::Length(3), Constraint::Length(2), ])
129 .split(inner);
130
131 let message = Paragraph::new(Line::from(vec![Span::styled(
133 "Your cleared balance doesn't match the statement balance.",
134 Style::default().fg(Color::White),
135 )]));
136 frame.render_widget(message, chunks[1]);
137
138 let amount_style = if state.adjustment_amount.is_negative() {
140 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
141 } else {
142 Style::default()
143 .fg(Color::Green)
144 .add_modifier(Modifier::BOLD)
145 };
146
147 let amount_text = Paragraph::new(Line::from(vec![
148 Span::styled("Adjustment needed: ", Style::default().fg(Color::Yellow)),
149 Span::styled(format!("{}", state.adjustment_amount), amount_style),
150 ]));
151 frame.render_widget(amount_text, chunks[2]);
152
153 let category_style = if state.selecting_category {
155 Style::default()
156 .fg(Color::Yellow)
157 .add_modifier(Modifier::BOLD)
158 } else {
159 Style::default().fg(Color::White)
160 };
161
162 let category_display = if state.category_input.is_empty() {
163 "(Optional) Enter category...".to_string()
164 } else {
165 state.category_input.clone()
166 };
167
168 let category_text = Paragraph::new(Line::from(vec![
169 Span::styled("Category: ", Style::default().fg(Color::Yellow)),
170 Span::styled(category_display, category_style),
171 ]));
172 frame.render_widget(category_text, chunks[3]);
173
174 if state.selecting_category {
176 let filtered = state.filtered_categories();
177 let items: Vec<Line> = filtered
178 .iter()
179 .enumerate()
180 .take(3)
181 .map(|(i, (_, name))| {
182 let style = if i == state.selected_index {
183 Style::default().bg(Color::DarkGray).fg(Color::White)
184 } else {
185 Style::default().fg(Color::Yellow)
186 };
187 Line::from(Span::styled(format!(" {}", name), style))
188 })
189 .collect();
190
191 let list = Paragraph::new(items);
192 frame.render_widget(list, chunks[4]);
193 }
194
195 let instructions = Paragraph::new(Line::from(vec![
197 Span::styled("[Enter]", Style::default().fg(Color::Green)),
198 Span::raw(" Create "),
199 Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
200 Span::raw(" Select category "),
201 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
202 Span::raw(" Cancel"),
203 ]));
204 frame.render_widget(instructions, chunks[5]);
205}
206
207pub fn handle_key(app: &mut App, key: crossterm::event::KeyCode) -> bool {
209 use crossterm::event::KeyCode;
210
211 let state = &mut app.adjustment_dialog_state;
212
213 match key {
214 KeyCode::Tab => {
215 state.selecting_category = !state.selecting_category;
216 true
217 }
218 KeyCode::Up | KeyCode::Char('k') if state.selecting_category => {
219 state.move_up();
220 true
221 }
222 KeyCode::Down | KeyCode::Char('j') if state.selecting_category => {
223 state.move_down();
224 true
225 }
226 KeyCode::Enter if state.selecting_category => {
227 state.select_current();
228 true
229 }
230 KeyCode::Char(c) if !state.selecting_category => {
231 state.category_input.push(c);
232 state.selecting_category = true;
233 state.selected_index = 0;
234 true
235 }
236 KeyCode::Backspace if !state.selecting_category => {
237 state.category_input.pop();
238 true
239 }
240 _ => false,
241 }
242}