envelope_cli/tui/dialogs/
group.rs

1//! Category group entry dialog
2//!
3//! Modal dialog for adding new category groups with form validation
4//! 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::CategoryGroup;
15use crate::services::CategoryService;
16use crate::tui::app::App;
17use crate::tui::layout::centered_rect;
18use crate::tui::widgets::input::TextInput;
19
20/// State for the group form dialog
21#[derive(Debug, Clone)]
22pub struct GroupFormState {
23    /// Name input
24    pub name_input: TextInput,
25
26    /// Error message to display
27    pub error_message: Option<String>,
28}
29
30impl Default for GroupFormState {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl GroupFormState {
37    /// Create a new form state with default values
38    pub fn new() -> Self {
39        Self {
40            name_input: TextInput::new()
41                .label("Name")
42                .placeholder("Group name (e.g., Bills, Savings)"),
43            error_message: None,
44        }
45    }
46
47    /// Validate the form and return any error
48    pub fn validate(&self) -> Result<(), String> {
49        let name = self.name_input.value().trim();
50        if name.is_empty() {
51            return Err("Group name is required".to_string());
52        }
53        if name.len() > 50 {
54            return Err("Group name too long (max 50 chars)".to_string());
55        }
56        Ok(())
57    }
58
59    /// Build a CategoryGroup from the form state
60    pub fn build_group(&self) -> Result<CategoryGroup, String> {
61        self.validate()?;
62        let name = self.name_input.value().trim().to_string();
63        Ok(CategoryGroup::new(name))
64    }
65
66    /// Clear any error message
67    pub fn clear_error(&mut self) {
68        self.error_message = None;
69    }
70
71    /// Set an error message
72    pub fn set_error(&mut self, msg: impl Into<String>) {
73        self.error_message = Some(msg.into());
74    }
75}
76
77/// Render the group dialog
78pub fn render(frame: &mut Frame, app: &mut App) {
79    let area = centered_rect(50, 25, frame.area());
80
81    // Clear the background
82    frame.render_widget(Clear, area);
83
84    let block = Block::default()
85        .title(" Add Category Group ")
86        .title_style(
87            Style::default()
88                .fg(Color::Cyan)
89                .add_modifier(Modifier::BOLD),
90        )
91        .borders(Borders::ALL)
92        .border_style(Style::default().fg(Color::Cyan));
93
94    frame.render_widget(block, area);
95
96    // Inner area for content
97    let inner = Rect {
98        x: area.x + 2,
99        y: area.y + 1,
100        width: area.width.saturating_sub(4),
101        height: area.height.saturating_sub(2),
102    };
103
104    // Layout: fields + buttons
105    let chunks = Layout::default()
106        .direction(Direction::Vertical)
107        .constraints([
108            Constraint::Length(1), // Name
109            Constraint::Length(1), // Spacer
110            Constraint::Length(1), // Error
111            Constraint::Length(1), // Buttons
112            Constraint::Min(0),    // Remaining
113        ])
114        .split(inner);
115
116    // Extract values to avoid borrow conflicts
117    let name_value = app.group_form.name_input.value().to_string();
118    let name_cursor = app.group_form.name_input.cursor;
119    let name_placeholder = app.group_form.name_input.placeholder.clone();
120    let error_message = app.group_form.error_message.clone();
121
122    // Render name field (always focused since it's the only field)
123    render_text_field(
124        frame,
125        chunks[0],
126        "Name",
127        &name_value,
128        true, // always focused
129        name_cursor,
130        &name_placeholder,
131    );
132
133    // Render error message if any
134    if let Some(ref error) = error_message {
135        let error_line = Line::from(Span::styled(
136            error.as_str(),
137            Style::default().fg(Color::Red),
138        ));
139        frame.render_widget(Paragraph::new(error_line), chunks[2]);
140    }
141
142    // Render buttons/hints
143    let hints = Line::from(vec![
144        Span::styled("[Enter]", Style::default().fg(Color::Green)),
145        Span::raw(" Save  "),
146        Span::styled("[Esc]", Style::default().fg(Color::Red)),
147        Span::raw(" Cancel"),
148    ]);
149    frame.render_widget(Paragraph::new(hints), chunks[3]);
150}
151
152/// Render a text field
153fn render_text_field(
154    frame: &mut Frame,
155    area: Rect,
156    label: &str,
157    value: &str,
158    focused: bool,
159    cursor: usize,
160    placeholder: &str,
161) {
162    let label_style = if focused {
163        Style::default()
164            .fg(Color::Cyan)
165            .add_modifier(Modifier::BOLD)
166    } else {
167        Style::default().fg(Color::Yellow)
168    };
169
170    let label_span = Span::styled(format!("{}: ", label), label_style);
171    let value_style = Style::default().fg(Color::White);
172
173    let display_value = if value.is_empty() && !focused {
174        placeholder.to_string()
175    } else {
176        value.to_string()
177    };
178
179    let mut spans = vec![label_span];
180
181    if focused {
182        let cursor_pos = cursor.min(display_value.len());
183        let (before, after) = display_value.split_at(cursor_pos);
184
185        spans.push(Span::styled(before.to_string(), value_style));
186
187        let cursor_char = after.chars().next().unwrap_or(' ');
188        spans.push(Span::styled(
189            cursor_char.to_string(),
190            Style::default().fg(Color::Black).bg(Color::Cyan),
191        ));
192
193        if after.len() > 1 {
194            spans.push(Span::styled(after[1..].to_string(), value_style));
195        }
196    } else {
197        spans.push(Span::styled(display_value, value_style));
198    }
199
200    frame.render_widget(Paragraph::new(Line::from(spans)), area);
201}
202
203/// Handle key input for the group dialog
204pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
205    use crossterm::event::KeyCode;
206
207    let form = &mut app.group_form;
208
209    match key.code {
210        KeyCode::Esc => {
211            app.close_dialog();
212            return true;
213        }
214
215        KeyCode::Enter => {
216            // Try to save
217            if let Err(e) = save_group(app) {
218                app.group_form.set_error(e);
219            }
220            return true;
221        }
222
223        KeyCode::Backspace => {
224            form.clear_error();
225            form.name_input.backspace();
226            return true;
227        }
228
229        KeyCode::Delete => {
230            form.clear_error();
231            form.name_input.delete();
232            return true;
233        }
234
235        KeyCode::Left => {
236            form.name_input.move_left();
237            return true;
238        }
239
240        KeyCode::Right => {
241            form.name_input.move_right();
242            return true;
243        }
244
245        KeyCode::Home => {
246            form.name_input.move_start();
247            return true;
248        }
249
250        KeyCode::End => {
251            form.name_input.move_end();
252            return true;
253        }
254
255        KeyCode::Char(c) => {
256            form.clear_error();
257            form.name_input.insert(c);
258            return true;
259        }
260
261        _ => {}
262    }
263
264    false
265}
266
267/// Save the group
268fn save_group(app: &mut App) -> Result<(), String> {
269    // Validate form
270    app.group_form.validate()?;
271
272    let name = app.group_form.name_input.value().trim().to_string();
273
274    // Use CategoryService to create the group
275    let category_service = CategoryService::new(app.storage);
276    category_service
277        .create_group(&name)
278        .map_err(|e| e.to_string())?;
279
280    // Save to disk
281    app.storage.categories.save().map_err(|e| e.to_string())?;
282
283    // Close dialog
284    app.close_dialog();
285    app.set_status(format!("Category group '{}' created", name));
286
287    Ok(())
288}