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