envelope_cli/tui/dialogs/
income.rs

1//! Income dialog
2//!
3//! A dialog for setting expected income for a budget period.
4//! Allows users to set, view, and remove income expectations.
5
6use 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/// Which field is focused in the income dialog
20#[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/// State for the income dialog
44#[derive(Debug, Clone, Default)]
45pub struct IncomeFormState {
46    /// The period being edited
47    pub period: Option<BudgetPeriod>,
48    /// Which field is focused
49    pub focused_field: IncomeField,
50    /// Amount input
51    pub amount_input: String,
52    /// Amount cursor position
53    pub amount_cursor: usize,
54    /// Notes input
55    pub notes_input: String,
56    /// Notes cursor position
57    pub notes_cursor: usize,
58    /// Whether there's an existing income expectation
59    pub has_existing: bool,
60    /// Current expected income (for display)
61    pub current_amount: Option<Money>,
62    /// Error message
63    pub error_message: Option<String>,
64}
65
66impl IncomeFormState {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Initialize the dialog for a period
72    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    /// Reset the state
101    pub fn reset(&mut self) {
102        *self = Self::default();
103    }
104
105    /// Set focus to a field
106    pub fn set_focus(&mut self, field: IncomeField) {
107        self.focused_field = field;
108    }
109
110    /// Move to next field
111    pub fn next_field(&mut self) {
112        self.focused_field = self.focused_field.next();
113    }
114
115    /// Move to previous field
116    pub fn prev_field(&mut self) {
117        self.focused_field = self.focused_field.prev();
118    }
119
120    /// Insert character into current field
121    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    /// Delete character before cursor
139    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    /// Move cursor left
159    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    /// Move cursor right
175    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    /// Clear current field
191    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    /// Parse the amount input
206    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    /// Get notes (None if empty)
214    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    /// Set error message
224    pub fn set_error(&mut self, msg: impl Into<String>) {
225        self.error_message = Some(msg.into());
226    }
227}
228
229/// Render the income dialog
230pub 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), // Current income (if exists)
253        Constraint::Length(1), // Spacer
254        Constraint::Length(1), // Amount label
255        Constraint::Length(1), // Amount input
256        Constraint::Length(1), // Spacer
257        Constraint::Length(1), // Notes label
258        Constraint::Length(1), // Notes input
259        Constraint::Length(1), // Spacer
260        Constraint::Length(1), // Error
261        Constraint::Length(1), // Instructions
262        Constraint::Min(0),
263    ];
264
265    if !state.has_existing {
266        constraints.remove(0); // Remove current income row if none exists
267        constraints.remove(0); // Remove spacer after current income
268    }
269
270    let chunks = Layout::default()
271        .direction(Direction::Vertical)
272        .constraints(constraints)
273        .split(inner);
274
275    let mut row = 0;
276
277    // Current income (if exists)
278    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; // Skip current and spacer
292    }
293
294    // Amount label
295    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    // Amount input with cursor
309    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; // Skip input and spacer
317
318    // Notes label
319    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    // Notes input with cursor
333    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; // Skip input and spacer
341
342    // Error message
343    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    // Instructions
353    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
414/// Handle key events for the income dialog
415pub 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}