envelope_cli/tui/dialogs/
category.rs

1//! Category entry dialog
2//!
3//! Modal dialog for adding new budget categories with form validation,
4//! group selection, 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, Paragraph},
11    Frame,
12};
13
14use crate::models::{Category, CategoryGroupId};
15use crate::services::CategoryService;
16use crate::tui::app::App;
17use crate::tui::layout::centered_rect;
18use crate::tui::widgets::input::TextInput;
19
20/// Which field is currently focused
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum CategoryField {
23    #[default]
24    Name,
25    Group,
26}
27
28/// State for the category form dialog
29#[derive(Debug, Clone)]
30pub struct CategoryFormState {
31    /// Name input
32    pub name_input: TextInput,
33
34    /// Selected group index
35    pub selected_group_index: usize,
36
37    /// Available groups (cached)
38    pub groups: Vec<(CategoryGroupId, String)>,
39
40    /// Currently focused field
41    pub focused_field: CategoryField,
42
43    /// Error message to display
44    pub error_message: Option<String>,
45
46    /// Category ID being edited (None for new category)
47    pub editing_id: Option<crate::models::CategoryId>,
48}
49
50impl Default for CategoryFormState {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl CategoryFormState {
57    /// Create a new form state with default values
58    pub fn new() -> Self {
59        Self {
60            name_input: TextInput::new()
61                .label("Name")
62                .placeholder("Category name (e.g., Groceries, Rent)"),
63            selected_group_index: 0,
64            groups: Vec::new(),
65            focused_field: CategoryField::Name,
66            error_message: None,
67            editing_id: None,
68        }
69    }
70
71    /// Initialize the form with available groups
72    pub fn init_with_groups(&mut self, groups: Vec<(CategoryGroupId, String)>) {
73        self.groups = groups;
74        self.selected_group_index = 0;
75        self.name_input = TextInput::new()
76            .label("Name")
77            .placeholder("Category name (e.g., Groceries, Rent)");
78        self.focused_field = CategoryField::Name;
79        self.error_message = None;
80        self.editing_id = None;
81    }
82
83    /// Initialize the form for editing an existing category
84    pub fn init_for_edit(&mut self, category: &Category, groups: Vec<(CategoryGroupId, String)>) {
85        self.groups = groups;
86        self.editing_id = Some(category.id);
87
88        // Set name input with existing value
89        self.name_input = TextInput::new()
90            .label("Name")
91            .placeholder("Category name (e.g., Groceries, Rent)")
92            .content(&category.name);
93
94        // Find and select the current group
95        self.selected_group_index = self
96            .groups
97            .iter()
98            .position(|(id, _)| *id == category.group_id)
99            .unwrap_or(0);
100
101        self.focused_field = CategoryField::Name;
102        self.error_message = None;
103    }
104
105    /// Set the focused field
106    pub fn set_focus(&mut self, field: CategoryField) {
107        self.focused_field = field;
108    }
109
110    /// Move to next field
111    pub fn next_field(&mut self) {
112        self.focused_field = match self.focused_field {
113            CategoryField::Name => CategoryField::Group,
114            CategoryField::Group => CategoryField::Name,
115        };
116    }
117
118    /// Move to previous field
119    pub fn prev_field(&mut self) {
120        self.focused_field = match self.focused_field {
121            CategoryField::Name => CategoryField::Group,
122            CategoryField::Group => CategoryField::Name,
123        };
124    }
125
126    /// Select next group
127    pub fn next_group(&mut self) {
128        if !self.groups.is_empty() {
129            self.selected_group_index = (self.selected_group_index + 1) % self.groups.len();
130        }
131    }
132
133    /// Select previous group
134    pub fn prev_group(&mut self) {
135        if !self.groups.is_empty() {
136            self.selected_group_index = if self.selected_group_index == 0 {
137                self.groups.len() - 1
138            } else {
139                self.selected_group_index - 1
140            };
141        }
142    }
143
144    /// Get selected group ID
145    pub fn selected_group_id(&self) -> Option<CategoryGroupId> {
146        self.groups
147            .get(self.selected_group_index)
148            .map(|(id, _)| *id)
149    }
150
151    /// Get selected group name
152    pub fn selected_group_name(&self) -> Option<&str> {
153        self.groups
154            .get(self.selected_group_index)
155            .map(|(_, name)| name.as_str())
156    }
157
158    /// Validate the form and return any error
159    pub fn validate(&self) -> Result<(), String> {
160        let name = self.name_input.value().trim();
161        if name.is_empty() {
162            return Err("Category name is required".to_string());
163        }
164        if name.len() > 50 {
165            return Err("Category name too long (max 50 chars)".to_string());
166        }
167        if self.groups.is_empty() {
168            return Err("No category groups available. Create a group first (Shift+A)".to_string());
169        }
170        Ok(())
171    }
172
173    /// Build a Category from the form state
174    pub fn build_category(&self) -> Result<Category, String> {
175        self.validate()?;
176        let name = self.name_input.value().trim().to_string();
177        let group_id = self
178            .selected_group_id()
179            .ok_or_else(|| "No group selected".to_string())?;
180        Ok(Category::new(&name, group_id))
181    }
182
183    /// Clear any error message
184    pub fn clear_error(&mut self) {
185        self.error_message = None;
186    }
187
188    /// Set an error message
189    pub fn set_error(&mut self, msg: impl Into<String>) {
190        self.error_message = Some(msg.into());
191    }
192}
193
194/// Render the category dialog
195pub fn render(frame: &mut Frame, app: &mut App) {
196    let area = centered_rect(50, 30, frame.area());
197
198    // Clear the background
199    frame.render_widget(Clear, area);
200
201    // Choose title based on whether we're editing or adding
202    let title = if app.category_form.editing_id.is_some() {
203        " Edit Category "
204    } else {
205        " Add Category "
206    };
207
208    let block = Block::default()
209        .title(title)
210        .title_style(
211            Style::default()
212                .fg(Color::Cyan)
213                .add_modifier(Modifier::BOLD),
214        )
215        .borders(Borders::ALL)
216        .border_style(Style::default().fg(Color::Cyan));
217
218    frame.render_widget(block, area);
219
220    // Inner area for content
221    let inner = Rect {
222        x: area.x + 2,
223        y: area.y + 1,
224        width: area.width.saturating_sub(4),
225        height: area.height.saturating_sub(2),
226    };
227
228    // Layout: fields + buttons
229    let chunks = Layout::default()
230        .direction(Direction::Vertical)
231        .constraints([
232            Constraint::Length(1), // Name label
233            Constraint::Length(1), // Spacer
234            Constraint::Length(1), // Group label
235            Constraint::Length(1), // Spacer
236            Constraint::Length(1), // Error
237            Constraint::Length(1), // Buttons
238            Constraint::Min(0),    // Remaining
239        ])
240        .split(inner);
241
242    // Extract values to avoid borrow conflicts
243    let name_value = app.category_form.name_input.value().to_string();
244    let name_cursor = app.category_form.name_input.cursor;
245    let name_placeholder = app.category_form.name_input.placeholder.clone();
246    let focused_field = app.category_form.focused_field;
247    let error_message = app.category_form.error_message.clone();
248    let group_name = app
249        .category_form
250        .selected_group_name()
251        .unwrap_or("(no groups)")
252        .to_string();
253
254    // Render name field
255    render_text_field(
256        frame,
257        chunks[0],
258        "Name",
259        &name_value,
260        focused_field == CategoryField::Name,
261        name_cursor,
262        &name_placeholder,
263    );
264
265    // Render group selector
266    render_selector_field(
267        frame,
268        chunks[2],
269        "Group",
270        &group_name,
271        focused_field == CategoryField::Group,
272    );
273
274    // Render error message if any
275    if let Some(ref error) = error_message {
276        let error_line = Line::from(Span::styled(
277            error.as_str(),
278            Style::default().fg(Color::Red),
279        ));
280        frame.render_widget(Paragraph::new(error_line), chunks[4]);
281    }
282
283    // Render buttons/hints
284    let hints = Line::from(vec![
285        Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
286        Span::raw(" Next  "),
287        Span::styled("[Enter]", Style::default().fg(Color::Green)),
288        Span::raw(" Save  "),
289        Span::styled("[Esc]", Style::default().fg(Color::Red)),
290        Span::raw(" Cancel"),
291    ]);
292    frame.render_widget(Paragraph::new(hints), chunks[5]);
293}
294
295/// Render a text field
296fn render_text_field(
297    frame: &mut Frame,
298    area: Rect,
299    label: &str,
300    value: &str,
301    focused: bool,
302    cursor: usize,
303    placeholder: &str,
304) {
305    let label_style = if focused {
306        Style::default()
307            .fg(Color::Cyan)
308            .add_modifier(Modifier::BOLD)
309    } else {
310        Style::default().fg(Color::Yellow)
311    };
312
313    let label_span = Span::styled(format!("{}: ", label), label_style);
314    let value_style = Style::default().fg(Color::White);
315
316    let display_value = if value.is_empty() && !focused {
317        placeholder.to_string()
318    } else {
319        value.to_string()
320    };
321
322    let mut spans = vec![label_span];
323
324    if focused {
325        let cursor_pos = cursor.min(display_value.len());
326        let (before, after) = display_value.split_at(cursor_pos);
327
328        spans.push(Span::styled(before.to_string(), value_style));
329
330        let cursor_char = after.chars().next().unwrap_or(' ');
331        spans.push(Span::styled(
332            cursor_char.to_string(),
333            Style::default().fg(Color::Black).bg(Color::Cyan),
334        ));
335
336        if after.len() > 1 {
337            spans.push(Span::styled(after[1..].to_string(), value_style));
338        }
339    } else {
340        spans.push(Span::styled(display_value, value_style));
341    }
342
343    frame.render_widget(Paragraph::new(Line::from(spans)), area);
344}
345
346/// Render a selector field (for group selection)
347fn render_selector_field(frame: &mut Frame, area: Rect, label: &str, value: &str, focused: bool) {
348    let label_style = if focused {
349        Style::default()
350            .fg(Color::Cyan)
351            .add_modifier(Modifier::BOLD)
352    } else {
353        Style::default().fg(Color::Yellow)
354    };
355
356    let value_style = if focused {
357        Style::default().fg(Color::White).bg(Color::DarkGray)
358    } else {
359        Style::default().fg(Color::White)
360    };
361
362    let hint = if focused { " ◀ j/k ▶" } else { "" };
363
364    let line = Line::from(vec![
365        Span::styled(format!("{}: ", label), label_style),
366        Span::styled(format!(" {} ", value), value_style),
367        Span::styled(hint, Style::default().fg(Color::Yellow)),
368    ]);
369
370    frame.render_widget(Paragraph::new(line), area);
371}
372
373/// Handle key input for the category dialog
374pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
375    use crossterm::event::KeyCode;
376
377    match key.code {
378        KeyCode::Esc => {
379            app.close_dialog();
380            return true;
381        }
382
383        KeyCode::Tab | KeyCode::Down if app.category_form.focused_field == CategoryField::Name => {
384            app.category_form.next_field();
385            return true;
386        }
387
388        KeyCode::BackTab | KeyCode::Up
389            if app.category_form.focused_field == CategoryField::Group =>
390        {
391            app.category_form.prev_field();
392            return true;
393        }
394
395        KeyCode::Enter => {
396            // Try to save
397            if let Err(e) = save_category(app) {
398                app.category_form.set_error(e);
399            }
400            return true;
401        }
402
403        _ => {}
404    }
405
406    // Field-specific handling
407    match app.category_form.focused_field {
408        CategoryField::Name => handle_name_input(app, key),
409        CategoryField::Group => handle_group_selector(app, key),
410    }
411}
412
413/// Handle input for the name field
414fn handle_name_input(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
415    use crossterm::event::KeyCode;
416
417    let form = &mut app.category_form;
418
419    match key.code {
420        KeyCode::Backspace => {
421            form.clear_error();
422            form.name_input.backspace();
423            true
424        }
425
426        KeyCode::Delete => {
427            form.clear_error();
428            form.name_input.delete();
429            true
430        }
431
432        KeyCode::Left => {
433            form.name_input.move_left();
434            true
435        }
436
437        KeyCode::Right => {
438            form.name_input.move_right();
439            true
440        }
441
442        KeyCode::Home => {
443            form.name_input.move_start();
444            true
445        }
446
447        KeyCode::End => {
448            form.name_input.move_end();
449            true
450        }
451
452        KeyCode::Char(c) => {
453            form.clear_error();
454            form.name_input.insert(c);
455            true
456        }
457
458        _ => false,
459    }
460}
461
462/// Handle input for the group selector
463fn handle_group_selector(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
464    use crossterm::event::KeyCode;
465
466    let form = &mut app.category_form;
467
468    match key.code {
469        KeyCode::Char('j') | KeyCode::Right => {
470            form.next_group();
471            true
472        }
473
474        KeyCode::Char('k') | KeyCode::Left => {
475            form.prev_group();
476            true
477        }
478
479        _ => false,
480    }
481}
482
483/// Save the category
484fn save_category(app: &mut App) -> Result<(), String> {
485    // Validate form
486    app.category_form.validate()?;
487
488    let name = app.category_form.name_input.value().trim().to_string();
489    let group_id = app
490        .category_form
491        .selected_group_id()
492        .ok_or_else(|| "No group selected".to_string())?;
493
494    let category_service = CategoryService::new(app.storage);
495
496    if let Some(category_id) = app.category_form.editing_id {
497        // Update existing category
498        category_service
499            .update_category(category_id, Some(&name), None, false)
500            .map_err(|e| e.to_string())?;
501
502        // If group changed, move the category
503        if let Ok(Some(cat)) = app.storage.categories.get_category(category_id) {
504            if cat.group_id != group_id {
505                category_service
506                    .move_category(category_id, group_id)
507                    .map_err(|e| e.to_string())?;
508            }
509        }
510
511        // Close dialog
512        app.close_dialog();
513        app.set_status(format!("Category '{}' updated", name));
514    } else {
515        // Create new category
516        category_service
517            .create_category(&name, group_id)
518            .map_err(|e| e.to_string())?;
519
520        // Close dialog
521        app.close_dialog();
522        app.set_status(format!("Category '{}' created", name));
523    }
524
525    Ok(())
526}