envelope_cli/tui/dialogs/
group.rs1use 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#[derive(Debug, Clone)]
22pub struct GroupFormState {
23 pub name_input: TextInput,
25
26 pub error_message: Option<String>,
28
29 pub editing_id: Option<CategoryGroupId>,
31}
32
33impl Default for GroupFormState {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl GroupFormState {
40 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 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 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 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 pub fn clear_error(&mut self) {
82 self.error_message = None;
83 }
84
85 pub fn set_error(&mut self, msg: impl Into<String>) {
87 self.error_message = Some(msg.into());
88 }
89}
90
91pub fn render(frame: &mut Frame, app: &mut App) {
93 let area = centered_rect(50, 25, frame.area());
94
95 frame.render_widget(Clear, area);
97
98 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 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 let chunks = Layout::default()
127 .direction(Direction::Vertical)
128 .constraints([
129 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
135 .split(inner);
136
137 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_text_field(
145 frame,
146 chunks[0],
147 "Name",
148 &name_value,
149 true, name_cursor,
151 &name_placeholder,
152 );
153
154 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 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
173fn 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
224pub 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 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
288fn save_group(app: &mut App) -> Result<(), String> {
290 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 category_service
299 .update_group(group_id, Some(&name))
300 .map_err(|e| e.to_string())?;
301
302 app.close_dialog();
304 app.set_status(format!("Category group '{}' updated", name));
305 } else {
306 category_service
308 .create_group(&name)
309 .map_err(|e| e.to_string())?;
310
311 app.storage.categories.save().map_err(|e| e.to_string())?;
313
314 app.close_dialog();
316 app.set_status(format!("Category group '{}' created", name));
317 }
318
319 Ok(())
320}