envelope_cli/tui/dialogs/
income.rs1use ratatui::{
7 layout::{Constraint, Direction, Layout},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, Clear, Paragraph},
11 Frame,
12};
13
14use crate::models::{BudgetPeriod, Money};
15use crate::services::IncomeService;
16use crate::tui::app::App;
17use crate::tui::layout::centered_rect_fixed;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum IncomeField {
22 #[default]
23 Amount,
24 Notes,
25}
26
27impl IncomeField {
28 pub fn next(self) -> Self {
29 match self {
30 Self::Amount => Self::Notes,
31 Self::Notes => Self::Amount,
32 }
33 }
34
35 pub fn prev(self) -> Self {
36 match self {
37 Self::Amount => Self::Notes,
38 Self::Notes => Self::Amount,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct IncomeFormState {
46 pub period: Option<BudgetPeriod>,
48 pub focused_field: IncomeField,
50 pub amount_input: String,
52 pub amount_cursor: usize,
54 pub notes_input: String,
56 pub notes_cursor: usize,
58 pub has_existing: bool,
60 pub current_amount: Option<Money>,
62 pub error_message: Option<String>,
64}
65
66impl IncomeFormState {
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn init_for_period(&mut self, period: &BudgetPeriod, storage: &crate::storage::Storage) {
73 self.period = Some(period.clone());
74 self.focused_field = IncomeField::Amount;
75 self.error_message = None;
76
77 let service = IncomeService::new(storage);
78 if let Some(expectation) = service.get_income_expectation(period) {
79 self.has_existing = true;
80 self.current_amount = Some(expectation.expected_amount);
81 let cents = expectation.expected_amount.cents();
82 if cents == 0 {
83 self.amount_input = String::new();
84 } else {
85 self.amount_input = format!("{:.2}", cents as f64 / 100.0);
86 }
87 self.amount_cursor = self.amount_input.len();
88 self.notes_input = expectation.notes.clone();
89 self.notes_cursor = self.notes_input.len();
90 } else {
91 self.has_existing = false;
92 self.current_amount = None;
93 self.amount_input = String::new();
94 self.amount_cursor = 0;
95 self.notes_input = String::new();
96 self.notes_cursor = 0;
97 }
98 }
99
100 pub fn reset(&mut self) {
102 *self = Self::default();
103 }
104
105 pub fn set_focus(&mut self, field: IncomeField) {
107 self.focused_field = field;
108 }
109
110 pub fn next_field(&mut self) {
112 self.focused_field = self.focused_field.next();
113 }
114
115 pub fn prev_field(&mut self) {
117 self.focused_field = self.focused_field.prev();
118 }
119
120 pub fn insert_char(&mut self, c: char) {
122 match self.focused_field {
123 IncomeField::Amount => {
124 if c.is_ascii_digit() || c == '.' {
125 self.amount_input.insert(self.amount_cursor, c);
126 self.amount_cursor += 1;
127 self.error_message = None;
128 }
129 }
130 IncomeField::Notes => {
131 self.notes_input.insert(self.notes_cursor, c);
132 self.notes_cursor += 1;
133 self.error_message = None;
134 }
135 }
136 }
137
138 pub fn backspace(&mut self) {
140 match self.focused_field {
141 IncomeField::Amount => {
142 if self.amount_cursor > 0 {
143 self.amount_cursor -= 1;
144 self.amount_input.remove(self.amount_cursor);
145 self.error_message = None;
146 }
147 }
148 IncomeField::Notes => {
149 if self.notes_cursor > 0 {
150 self.notes_cursor -= 1;
151 self.notes_input.remove(self.notes_cursor);
152 self.error_message = None;
153 }
154 }
155 }
156 }
157
158 pub fn move_left(&mut self) {
160 match self.focused_field {
161 IncomeField::Amount => {
162 if self.amount_cursor > 0 {
163 self.amount_cursor -= 1;
164 }
165 }
166 IncomeField::Notes => {
167 if self.notes_cursor > 0 {
168 self.notes_cursor -= 1;
169 }
170 }
171 }
172 }
173
174 pub fn move_right(&mut self) {
176 match self.focused_field {
177 IncomeField::Amount => {
178 if self.amount_cursor < self.amount_input.len() {
179 self.amount_cursor += 1;
180 }
181 }
182 IncomeField::Notes => {
183 if self.notes_cursor < self.notes_input.len() {
184 self.notes_cursor += 1;
185 }
186 }
187 }
188 }
189
190 pub fn clear_field(&mut self) {
192 match self.focused_field {
193 IncomeField::Amount => {
194 self.amount_input.clear();
195 self.amount_cursor = 0;
196 }
197 IncomeField::Notes => {
198 self.notes_input.clear();
199 self.notes_cursor = 0;
200 }
201 }
202 self.error_message = None;
203 }
204
205 pub fn parse_amount(&self) -> Result<Money, String> {
207 if self.amount_input.trim().is_empty() {
208 return Err("Amount is required".to_string());
209 }
210 Money::parse(&self.amount_input).map_err(|_| "Invalid amount format".to_string())
211 }
212
213 pub fn get_notes(&self) -> Option<String> {
215 let trimmed = self.notes_input.trim();
216 if trimmed.is_empty() {
217 None
218 } else {
219 Some(trimmed.to_string())
220 }
221 }
222
223 pub fn set_error(&mut self, msg: impl Into<String>) {
225 self.error_message = Some(msg.into());
226 }
227}
228
229pub fn render(frame: &mut Frame, app: &App) {
231 let state = &app.income_form;
232
233 let height = if state.has_existing { 14 } else { 12 };
234 let area = centered_rect_fixed(55, height, frame.area());
235 frame.render_widget(Clear, area);
236
237 let title = format!(" Expected Income: {} ", app.current_period);
238 let block = Block::default()
239 .title(title)
240 .title_style(
241 Style::default()
242 .fg(Color::Cyan)
243 .add_modifier(Modifier::BOLD),
244 )
245 .borders(Borders::ALL)
246 .border_style(Style::default().fg(Color::Cyan));
247
248 let inner = block.inner(area);
249 frame.render_widget(block, area);
250
251 let mut constraints = vec![
252 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0),
263 ];
264
265 if !state.has_existing {
266 constraints.remove(0); constraints.remove(0); }
269
270 let chunks = Layout::default()
271 .direction(Direction::Vertical)
272 .constraints(constraints)
273 .split(inner);
274
275 let mut row = 0;
276
277 if state.has_existing {
279 if let Some(current) = state.current_amount {
280 let current_line = Line::from(vec![
281 Span::styled("Current: ", Style::default().fg(Color::Yellow)),
282 Span::styled(
283 format!("{}", current),
284 Style::default()
285 .fg(Color::Green)
286 .add_modifier(Modifier::BOLD),
287 ),
288 ]);
289 frame.render_widget(Paragraph::new(current_line), chunks[row]);
290 }
291 row += 2; }
293
294 let amount_label_style = if state.focused_field == IncomeField::Amount {
296 Style::default()
297 .fg(Color::Cyan)
298 .add_modifier(Modifier::BOLD)
299 } else {
300 Style::default().fg(Color::Yellow)
301 };
302 frame.render_widget(
303 Paragraph::new(Span::styled("Amount:", amount_label_style)),
304 chunks[row],
305 );
306 row += 1;
307
308 let amount_line = render_input_with_cursor(
310 "$",
311 &state.amount_input,
312 state.amount_cursor,
313 state.focused_field == IncomeField::Amount,
314 );
315 frame.render_widget(Paragraph::new(amount_line), chunks[row]);
316 row += 2; let notes_label_style = if state.focused_field == IncomeField::Notes {
320 Style::default()
321 .fg(Color::Cyan)
322 .add_modifier(Modifier::BOLD)
323 } else {
324 Style::default().fg(Color::Yellow)
325 };
326 frame.render_widget(
327 Paragraph::new(Span::styled("Notes (optional):", notes_label_style)),
328 chunks[row],
329 );
330 row += 1;
331
332 let notes_line = render_input_with_cursor(
334 "",
335 &state.notes_input,
336 state.notes_cursor,
337 state.focused_field == IncomeField::Notes,
338 );
339 frame.render_widget(Paragraph::new(notes_line), chunks[row]);
340 row += 2; if let Some(ref error) = state.error_message {
344 let error_line = Line::from(Span::styled(
345 error.as_str(),
346 Style::default().fg(Color::Red),
347 ));
348 frame.render_widget(Paragraph::new(error_line), chunks[row]);
349 }
350 row += 1;
351
352 let mut instructions = vec![
354 Span::styled("[Enter]", Style::default().fg(Color::Green)),
355 Span::raw(" Save "),
356 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
357 Span::raw(" Cancel "),
358 Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
359 Span::raw(" Fields"),
360 ];
361
362 if state.has_existing {
363 instructions.push(Span::raw(" "));
364 instructions.push(Span::styled("[Del]", Style::default().fg(Color::Magenta)));
365 instructions.push(Span::raw(" Remove"));
366 }
367
368 frame.render_widget(Paragraph::new(Line::from(instructions)), chunks[row]);
369}
370
371fn render_input_with_cursor(
372 prefix: &str,
373 value: &str,
374 cursor: usize,
375 focused: bool,
376) -> Line<'static> {
377 let mut spans = vec![];
378
379 if !prefix.is_empty() {
380 spans.push(Span::raw(prefix.to_string()));
381 }
382
383 if focused {
384 let cursor_pos = cursor.min(value.len());
385 let (before, after) = value.split_at(cursor_pos);
386
387 spans.push(Span::styled(
388 before.to_string(),
389 Style::default().fg(Color::White),
390 ));
391
392 let cursor_char = after.chars().next().unwrap_or(' ');
393 spans.push(Span::styled(
394 cursor_char.to_string(),
395 Style::default().fg(Color::Black).bg(Color::Cyan),
396 ));
397
398 if after.len() > 1 {
399 spans.push(Span::styled(
400 after[1..].to_string(),
401 Style::default().fg(Color::White),
402 ));
403 }
404 } else {
405 spans.push(Span::styled(
406 value.to_string(),
407 Style::default().fg(Color::White),
408 ));
409 }
410
411 Line::from(spans)
412}
413
414pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
416 use crossterm::event::{KeyCode, KeyModifiers};
417
418 match key.code {
419 KeyCode::Esc => {
420 app.income_form.reset();
421 app.close_dialog();
422 true
423 }
424
425 KeyCode::Tab => {
426 app.income_form.next_field();
427 true
428 }
429
430 KeyCode::BackTab => {
431 app.income_form.prev_field();
432 true
433 }
434
435 KeyCode::Enter => {
436 if let Err(e) = save_income(app) {
437 app.income_form.set_error(e);
438 }
439 true
440 }
441
442 KeyCode::Delete => {
443 if app.income_form.has_existing {
444 if let Err(e) = remove_income(app) {
445 app.income_form.set_error(e);
446 }
447 }
448 true
449 }
450
451 KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() => {
452 app.income_form.next_field();
453 true
454 }
455
456 KeyCode::Up | KeyCode::Char('k')
457 if key.modifiers.is_empty() && app.income_form.focused_field == IncomeField::Notes =>
458 {
459 app.income_form.prev_field();
460 true
461 }
462
463 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
464 app.income_form.clear_field();
465 true
466 }
467
468 KeyCode::Char(c) => {
469 app.income_form.insert_char(c);
470 true
471 }
472
473 KeyCode::Backspace => {
474 app.income_form.backspace();
475 true
476 }
477
478 KeyCode::Left => {
479 app.income_form.move_left();
480 true
481 }
482
483 KeyCode::Right => {
484 app.income_form.move_right();
485 true
486 }
487
488 _ => false,
489 }
490}
491
492fn save_income(app: &mut App) -> Result<(), String> {
493 let period = app.income_form.period.clone().ok_or("No period selected")?;
494 let amount = app.income_form.parse_amount()?;
495 let notes = app.income_form.get_notes();
496
497 let service = IncomeService::new(app.storage);
498 service
499 .set_expected_income(&period, amount, notes)
500 .map_err(|e| e.to_string())?;
501
502 app.income_form.reset();
503 app.close_dialog();
504 app.set_status(format!("Expected income for {} set to {}", period, amount));
505
506 Ok(())
507}
508
509fn remove_income(app: &mut App) -> Result<(), String> {
510 let period = app.income_form.period.clone().ok_or("No period selected")?;
511
512 let service = IncomeService::new(app.storage);
513 if service
514 .delete_expected_income(&period)
515 .map_err(|e| e.to_string())?
516 {
517 app.income_form.reset();
518 app.close_dialog();
519 app.set_status(format!("Expected income removed for {}", period));
520 Ok(())
521 } else {
522 Err("No income expectation to remove".to_string())
523 }
524}