envelope_cli/tui/dialogs/
account.rs

1//! Account entry dialog
2//!
3//! Modal dialog for adding new accounts with form fields,
4//! tab navigation, validation, and save/cancel functionality.
5
6use ratatui::{
7    layout::{Constraint, Direction, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
11    Frame,
12};
13
14use crate::models::{Account, AccountType, Money};
15use crate::tui::app::App;
16use crate::tui::layout::centered_rect;
17use crate::tui::widgets::input::TextInput;
18
19/// Account types available for selection
20const ACCOUNT_TYPES: &[AccountType] = &[
21    AccountType::Checking,
22    AccountType::Savings,
23    AccountType::Credit,
24    AccountType::Cash,
25    AccountType::Investment,
26    AccountType::LineOfCredit,
27    AccountType::Other,
28];
29
30/// Which field is currently focused in the account form
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum AccountField {
33    #[default]
34    Name,
35    AccountType,
36    StartingBalance,
37    OnBudget,
38}
39
40impl AccountField {
41    /// Get the next field (for Tab navigation)
42    pub fn next(self) -> Self {
43        match self {
44            Self::Name => Self::AccountType,
45            Self::AccountType => Self::StartingBalance,
46            Self::StartingBalance => Self::OnBudget,
47            Self::OnBudget => Self::Name,
48        }
49    }
50
51    /// Get the previous field (for Shift+Tab navigation)
52    pub fn prev(self) -> Self {
53        match self {
54            Self::Name => Self::OnBudget,
55            Self::AccountType => Self::Name,
56            Self::StartingBalance => Self::AccountType,
57            Self::OnBudget => Self::StartingBalance,
58        }
59    }
60}
61
62/// State for the account form dialog
63#[derive(Debug, Clone)]
64pub struct AccountFormState {
65    /// Currently focused field
66    pub focused_field: AccountField,
67
68    /// Name input
69    pub name_input: TextInput,
70
71    /// Selected account type index
72    pub account_type_index: usize,
73
74    /// Starting balance input
75    pub balance_input: TextInput,
76
77    /// Whether account is on budget
78    pub on_budget: bool,
79
80    /// Whether this is an edit (vs new account)
81    pub is_edit: bool,
82
83    /// Account ID being edited (if editing)
84    pub editing_account_id: Option<crate::models::AccountId>,
85
86    /// Error message to display
87    pub error_message: Option<String>,
88}
89
90impl Default for AccountFormState {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl AccountFormState {
97    /// Create a new form state with default values
98    pub fn new() -> Self {
99        Self {
100            focused_field: AccountField::Name,
101            name_input: TextInput::new().label("Name").placeholder("Account name"),
102            account_type_index: 0, // Checking
103            balance_input: TextInput::new()
104                .label("Balance")
105                .placeholder("0.00")
106                .content("0.00"),
107            on_budget: true,
108            is_edit: false,
109            editing_account_id: None,
110            error_message: None,
111        }
112    }
113
114    /// Create form state pre-populated from an existing account
115    pub fn from_account(account: &Account) -> Self {
116        // Find the account type index
117        let account_type_index = ACCOUNT_TYPES
118            .iter()
119            .position(|t| *t == account.account_type)
120            .unwrap_or(0);
121
122        Self {
123            focused_field: AccountField::Name,
124            name_input: TextInput::new().label("Name").content(&account.name),
125            account_type_index,
126            balance_input: TextInput::new().label("Balance").content(format!(
127                "{:.2}",
128                account.starting_balance.cents() as f64 / 100.0
129            )),
130            on_budget: account.on_budget,
131            is_edit: true,
132            editing_account_id: Some(account.id),
133            error_message: None,
134        }
135    }
136
137    /// Move to the next field
138    pub fn next_field(&mut self) {
139        self.focused_field = self.focused_field.next();
140    }
141
142    /// Move to the previous field
143    pub fn prev_field(&mut self) {
144        self.focused_field = self.focused_field.prev();
145    }
146
147    /// Set focus to a specific field
148    pub fn set_focus(&mut self, field: AccountField) {
149        self.focused_field = field;
150    }
151
152    /// Get the currently focused text input (if applicable)
153    pub fn focused_input(&mut self) -> Option<&mut TextInput> {
154        match self.focused_field {
155            AccountField::Name => Some(&mut self.name_input),
156            AccountField::StartingBalance => Some(&mut self.balance_input),
157            _ => None,
158        }
159    }
160
161    /// Get selected account type
162    pub fn selected_account_type(&self) -> AccountType {
163        ACCOUNT_TYPES
164            .get(self.account_type_index)
165            .copied()
166            .unwrap_or(AccountType::Checking)
167    }
168
169    /// Move to next account type
170    pub fn next_account_type(&mut self) {
171        self.account_type_index = (self.account_type_index + 1) % ACCOUNT_TYPES.len();
172    }
173
174    /// Move to previous account type
175    pub fn prev_account_type(&mut self) {
176        if self.account_type_index == 0 {
177            self.account_type_index = ACCOUNT_TYPES.len() - 1;
178        } else {
179            self.account_type_index -= 1;
180        }
181    }
182
183    /// Toggle on-budget status
184    pub fn toggle_on_budget(&mut self) {
185        self.on_budget = !self.on_budget;
186    }
187
188    /// Validate the form and return any error
189    pub fn validate(&self) -> Result<(), String> {
190        let name = self.name_input.value().trim();
191        if name.is_empty() {
192            return Err("Account name is required".to_string());
193        }
194        if name.len() > 100 {
195            return Err("Account name too long (max 100 chars)".to_string());
196        }
197
198        // Validate balance format
199        let balance_str = self.balance_input.value().trim();
200        if !balance_str.is_empty() && Money::parse(balance_str).is_err() {
201            return Err("Invalid balance format".to_string());
202        }
203
204        Ok(())
205    }
206
207    /// Build an account from the form state
208    pub fn build_account(&self) -> Result<Account, String> {
209        self.validate()?;
210
211        let name = self.name_input.value().trim().to_string();
212        let account_type = self.selected_account_type();
213
214        let balance_str = self.balance_input.value().trim();
215        let starting_balance = if balance_str.is_empty() {
216            Money::zero()
217        } else {
218            Money::parse(balance_str).map_err(|_| "Invalid balance")?
219        };
220
221        let mut account = Account::with_starting_balance(name, account_type, starting_balance);
222        account.on_budget = self.on_budget;
223
224        Ok(account)
225    }
226
227    /// Clear any error message
228    pub fn clear_error(&mut self) {
229        self.error_message = None;
230    }
231
232    /// Set an error message
233    pub fn set_error(&mut self, msg: impl Into<String>) {
234        self.error_message = Some(msg.into());
235    }
236}
237
238/// Render the account dialog
239pub fn render(frame: &mut Frame, app: &mut App) {
240    let area = centered_rect(60, 50, frame.area());
241
242    // Clear the background
243    frame.render_widget(Clear, area);
244
245    let title = if app.account_form.is_edit {
246        " Edit Account "
247    } else {
248        " Add Account "
249    };
250
251    let block = Block::default()
252        .title(title)
253        .title_style(
254            Style::default()
255                .fg(Color::Cyan)
256                .add_modifier(Modifier::BOLD),
257        )
258        .borders(Borders::ALL)
259        .border_style(Style::default().fg(Color::Cyan));
260
261    frame.render_widget(block, area);
262
263    // Inner area for content
264    let inner = Rect {
265        x: area.x + 2,
266        y: area.y + 1,
267        width: area.width.saturating_sub(4),
268        height: area.height.saturating_sub(2),
269    };
270
271    // Layout: fields + buttons
272    let chunks = Layout::default()
273        .direction(Direction::Vertical)
274        .constraints([
275            Constraint::Length(1), // Name
276            Constraint::Length(1), // Spacer
277            Constraint::Length(1), // Account Type label
278            Constraint::Length(5), // Account Type list
279            Constraint::Length(1), // Starting Balance
280            Constraint::Length(1), // On Budget
281            Constraint::Length(1), // Spacer
282            Constraint::Length(1), // Error
283            Constraint::Length(1), // Buttons
284            Constraint::Min(0),    // Remaining
285        ])
286        .split(inner);
287
288    // Extract values to avoid borrow conflicts
289    let name_value = app.account_form.name_input.value().to_string();
290    let name_focused = app.account_form.focused_field == AccountField::Name;
291    let name_cursor = app.account_form.name_input.cursor;
292    let name_placeholder = app.account_form.name_input.placeholder.clone();
293
294    let balance_value = app.account_form.balance_input.value().to_string();
295    let balance_focused = app.account_form.focused_field == AccountField::StartingBalance;
296    let balance_cursor = app.account_form.balance_input.cursor;
297    let balance_placeholder = app.account_form.balance_input.placeholder.clone();
298
299    let type_focused = app.account_form.focused_field == AccountField::AccountType;
300    let budget_focused = app.account_form.focused_field == AccountField::OnBudget;
301    let on_budget = app.account_form.on_budget;
302    let error_message = app.account_form.error_message.clone();
303
304    // Render name field
305    render_text_field(
306        frame,
307        chunks[0],
308        "Name",
309        &name_value,
310        name_focused,
311        name_cursor,
312        &name_placeholder,
313    );
314
315    // Render account type label
316    let type_label_style = if type_focused {
317        Style::default()
318            .fg(Color::Cyan)
319            .add_modifier(Modifier::BOLD)
320    } else {
321        Style::default().fg(Color::Yellow)
322    };
323    let type_label = Paragraph::new(Line::from(vec![
324        Span::styled("Type: ", type_label_style),
325        Span::styled("(↑/↓ to change)", Style::default().fg(Color::White)),
326    ]));
327    frame.render_widget(type_label, chunks[2]);
328
329    // Render account type list
330    render_account_type_list(frame, app, chunks[3]);
331
332    // Render starting balance field
333    render_text_field(
334        frame,
335        chunks[4],
336        "Balance",
337        &balance_value,
338        balance_focused,
339        balance_cursor,
340        &balance_placeholder,
341    );
342
343    // Render on budget toggle
344    let budget_label_style = if budget_focused {
345        Style::default()
346            .fg(Color::Cyan)
347            .add_modifier(Modifier::BOLD)
348    } else {
349        Style::default().fg(Color::Yellow)
350    };
351    let budget_value = if on_budget { "[x] Yes" } else { "[ ] No" };
352    let budget_hint = if budget_focused {
353        " (Space to toggle)"
354    } else {
355        ""
356    };
357    let budget_line = Line::from(vec![
358        Span::styled("On Budget: ", budget_label_style),
359        Span::styled(budget_value, Style::default().fg(Color::White)),
360        Span::styled(budget_hint, Style::default().fg(Color::White)),
361    ]);
362    frame.render_widget(Paragraph::new(budget_line), chunks[5]);
363
364    // Render error message if any
365    if let Some(ref error) = error_message {
366        let error_line = Line::from(Span::styled(
367            error.as_str(),
368            Style::default().fg(Color::Red),
369        ));
370        frame.render_widget(Paragraph::new(error_line), chunks[7]);
371    }
372
373    // Render buttons/hints
374    let hints = Line::from(vec![
375        Span::styled("[Tab]", Style::default().fg(Color::White)),
376        Span::raw(" Next  "),
377        Span::styled("[Enter]", Style::default().fg(Color::Green)),
378        Span::raw(" Save  "),
379        Span::styled("[Esc]", Style::default().fg(Color::Red)),
380        Span::raw(" Cancel"),
381    ]);
382    frame.render_widget(Paragraph::new(hints), chunks[8]);
383}
384
385/// Render a text field
386fn render_text_field(
387    frame: &mut Frame,
388    area: Rect,
389    label: &str,
390    value: &str,
391    focused: bool,
392    cursor: usize,
393    placeholder: &str,
394) {
395    let label_style = if focused {
396        Style::default()
397            .fg(Color::Cyan)
398            .add_modifier(Modifier::BOLD)
399    } else {
400        Style::default().fg(Color::Yellow)
401    };
402
403    let label_span = Span::styled(format!("{}: ", label), label_style);
404
405    let value_style = Style::default().fg(Color::White);
406
407    let display_value = if value.is_empty() && !focused {
408        placeholder.to_string()
409    } else {
410        value.to_string()
411    };
412
413    let mut spans = vec![label_span];
414
415    if focused {
416        let cursor_pos = cursor.min(display_value.len());
417        let (before, after) = display_value.split_at(cursor_pos);
418
419        spans.push(Span::styled(before.to_string(), value_style));
420
421        let cursor_char = after.chars().next().unwrap_or(' ');
422        spans.push(Span::styled(
423            cursor_char.to_string(),
424            Style::default().fg(Color::Black).bg(Color::Cyan),
425        ));
426
427        if after.len() > 1 {
428            spans.push(Span::styled(after[1..].to_string(), value_style));
429        }
430    } else {
431        spans.push(Span::styled(display_value, value_style));
432    }
433
434    frame.render_widget(Paragraph::new(Line::from(spans)), area);
435}
436
437/// Render the account type selection list
438fn render_account_type_list(frame: &mut Frame, app: &mut App, area: Rect) {
439    let form = &app.account_form;
440    let focused = form.focused_field == AccountField::AccountType;
441
442    let items: Vec<ListItem> = ACCOUNT_TYPES
443        .iter()
444        .map(|t| {
445            ListItem::new(Line::from(Span::styled(
446                format!("  {}", t),
447                Style::default().fg(Color::White),
448            )))
449        })
450        .collect();
451
452    let list = List::new(items)
453        .highlight_style(
454            Style::default()
455                .bg(Color::DarkGray)
456                .add_modifier(Modifier::BOLD),
457        )
458        .highlight_symbol("▶ ");
459
460    let mut state = ListState::default();
461    state.select(Some(form.account_type_index));
462
463    if focused {
464        frame.render_stateful_widget(list, area, &mut state);
465    } else {
466        // Show hint when not focused
467        let hint = Paragraph::new("  (Tab to this field to select)")
468            .style(Style::default().fg(Color::White));
469        frame.render_widget(hint, area);
470    }
471}
472
473/// Handle key input for the account dialog
474pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
475    use crossterm::event::{KeyCode, KeyModifiers};
476
477    let form = &mut app.account_form;
478
479    match key.code {
480        KeyCode::Esc => {
481            app.close_dialog();
482            return true;
483        }
484
485        KeyCode::Tab => {
486            if key.modifiers.contains(KeyModifiers::SHIFT) {
487                form.prev_field();
488            } else {
489                form.next_field();
490            }
491            return true;
492        }
493
494        KeyCode::BackTab => {
495            form.prev_field();
496            return true;
497        }
498
499        KeyCode::Enter => {
500            // Try to save
501            if let Err(e) = save_account(app) {
502                app.account_form.set_error(e);
503            }
504            return true;
505        }
506
507        KeyCode::Up => {
508            if form.focused_field == AccountField::AccountType {
509                form.prev_account_type();
510                return true;
511            }
512        }
513
514        KeyCode::Down => {
515            if form.focused_field == AccountField::AccountType {
516                form.next_account_type();
517                return true;
518            }
519        }
520
521        KeyCode::Char(' ') => {
522            if form.focused_field == AccountField::OnBudget {
523                form.toggle_on_budget();
524                return true;
525            }
526            // Otherwise fall through to character input
527        }
528
529        KeyCode::Backspace => {
530            form.clear_error();
531            if let Some(input) = form.focused_input() {
532                input.backspace();
533            }
534            return true;
535        }
536
537        KeyCode::Delete => {
538            form.clear_error();
539            if let Some(input) = form.focused_input() {
540                input.delete();
541            }
542            return true;
543        }
544
545        KeyCode::Left => {
546            if let Some(input) = form.focused_input() {
547                input.move_left();
548            }
549            return true;
550        }
551
552        KeyCode::Right => {
553            if let Some(input) = form.focused_input() {
554                input.move_right();
555            }
556            return true;
557        }
558
559        KeyCode::Home => {
560            if let Some(input) = form.focused_input() {
561                input.move_start();
562            }
563            return true;
564        }
565
566        KeyCode::End => {
567            if let Some(input) = form.focused_input() {
568                input.move_end();
569            }
570            return true;
571        }
572
573        KeyCode::Char(c) => {
574            form.clear_error();
575            if let Some(input) = form.focused_input() {
576                input.insert(c);
577            }
578            return true;
579        }
580
581        _ => {}
582    }
583
584    false
585}
586
587/// Save the account
588fn save_account(app: &mut App) -> Result<(), String> {
589    // Validate form
590    app.account_form.validate()?;
591
592    let is_edit = app.account_form.is_edit;
593    let editing_id = app.account_form.editing_account_id;
594
595    if is_edit {
596        // Update existing account
597        if let Some(account_id) = editing_id {
598            if let Ok(Some(mut existing)) = app.storage.accounts.get(account_id) {
599                existing.name = app.account_form.name_input.value().trim().to_string();
600                existing.account_type = app.account_form.selected_account_type();
601                existing.on_budget = app.account_form.on_budget;
602
603                // Update starting balance
604                let balance_str = app.account_form.balance_input.value().trim();
605                existing.starting_balance = if balance_str.is_empty() {
606                    Money::zero()
607                } else {
608                    Money::parse(balance_str).map_err(|_| "Invalid balance")?
609                };
610
611                existing.updated_at = chrono::Utc::now();
612
613                let account_name = existing.name.clone();
614                app.storage
615                    .accounts
616                    .upsert(existing)
617                    .map_err(|e| e.to_string())?;
618
619                app.storage.accounts.save().map_err(|e| e.to_string())?;
620                app.close_dialog();
621                app.set_status(format!("Account '{}' updated", account_name));
622            }
623        }
624    } else {
625        // Build new account
626        let account = app.account_form.build_account()?;
627        let account_name = account.name.clone();
628
629        // Save to storage
630        app.storage
631            .accounts
632            .upsert(account)
633            .map_err(|e| e.to_string())?;
634
635        // Save to disk
636        app.storage.accounts.save().map_err(|e| e.to_string())?;
637
638        // Close dialog
639        app.close_dialog();
640        app.set_status(format!("Account '{}' created", account_name));
641    }
642
643    Ok(())
644}