use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::models::{CategoryGroup, CategoryGroupId};
use crate::services::CategoryService;
use crate::tui::app::App;
use crate::tui::layout::centered_rect;
use crate::tui::widgets::input::TextInput;
#[derive(Debug, Clone)]
pub struct GroupFormState {
pub name_input: TextInput,
pub error_message: Option<String>,
pub editing_id: Option<CategoryGroupId>,
}
impl Default for GroupFormState {
fn default() -> Self {
Self::new()
}
}
impl GroupFormState {
pub fn new() -> Self {
Self {
name_input: TextInput::new()
.label("Name")
.placeholder("Group name (e.g., Bills, Savings)"),
error_message: None,
editing_id: None,
}
}
pub fn init_for_edit(&mut self, group: &CategoryGroup) {
self.editing_id = Some(group.id);
self.name_input = TextInput::new()
.label("Name")
.placeholder("Group name (e.g., Bills, Savings)")
.content(&group.name);
self.error_message = None;
}
pub fn validate(&self) -> Result<(), String> {
let name = self.name_input.value().trim();
if name.is_empty() {
return Err("Group name is required".to_string());
}
if name.len() > 50 {
return Err("Group name too long (max 50 chars)".to_string());
}
Ok(())
}
pub fn build_group(&self) -> Result<CategoryGroup, String> {
self.validate()?;
let name = self.name_input.value().trim().to_string();
Ok(CategoryGroup::new(name))
}
pub fn clear_error(&mut self) {
self.error_message = None;
}
pub fn set_error(&mut self, msg: impl Into<String>) {
self.error_message = Some(msg.into());
}
}
pub fn render(frame: &mut Frame, app: &mut App) {
let area = centered_rect(50, 25, frame.area());
frame.render_widget(Clear, area);
let title = if app.group_form.editing_id.is_some() {
" Edit Category Group "
} else {
" Add Category Group "
};
let block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
frame.render_widget(block, area);
let inner = Rect {
x: area.x + 2,
y: area.y + 1,
width: area.width.saturating_sub(4),
height: area.height.saturating_sub(2),
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
let name_value = app.group_form.name_input.value().to_string();
let name_cursor = app.group_form.name_input.cursor;
let name_placeholder = app.group_form.name_input.placeholder.clone();
let error_message = app.group_form.error_message.clone();
render_text_field(
frame,
chunks[0],
"Name",
&name_value,
true, name_cursor,
&name_placeholder,
);
if let Some(ref error) = error_message {
let error_line = Line::from(Span::styled(
error.as_str(),
Style::default().fg(Color::Red),
));
frame.render_widget(Paragraph::new(error_line), chunks[2]);
}
let hints = Line::from(vec![
Span::styled("[Enter]", Style::default().fg(Color::Green)),
Span::raw(" Save "),
Span::styled("[Esc]", Style::default().fg(Color::Red)),
Span::raw(" Cancel"),
]);
frame.render_widget(Paragraph::new(hints), chunks[3]);
}
fn render_text_field(
frame: &mut Frame,
area: Rect,
label: &str,
value: &str,
focused: bool,
cursor: usize,
placeholder: &str,
) {
let label_style = if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Yellow)
};
let label_span = Span::styled(format!("{}: ", label), label_style);
let value_style = Style::default().fg(Color::White);
let display_value = if value.is_empty() && !focused {
placeholder.to_string()
} else {
value.to_string()
};
let mut spans = vec![label_span];
if focused {
let cursor_pos = cursor.min(display_value.len());
let (before, after) = display_value.split_at(cursor_pos);
spans.push(Span::styled(before.to_string(), value_style));
let cursor_char = after.chars().next().unwrap_or(' ');
spans.push(Span::styled(
cursor_char.to_string(),
Style::default().fg(Color::Black).bg(Color::Cyan),
));
if after.len() > 1 {
spans.push(Span::styled(after[1..].to_string(), value_style));
}
} else {
spans.push(Span::styled(display_value, value_style));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
use crossterm::event::KeyCode;
let form = &mut app.group_form;
match key.code {
KeyCode::Esc => {
app.close_dialog();
return true;
}
KeyCode::Enter => {
if let Err(e) = save_group(app) {
app.group_form.set_error(e);
}
return true;
}
KeyCode::Backspace => {
form.clear_error();
form.name_input.backspace();
return true;
}
KeyCode::Delete => {
form.clear_error();
form.name_input.delete();
return true;
}
KeyCode::Left => {
form.name_input.move_left();
return true;
}
KeyCode::Right => {
form.name_input.move_right();
return true;
}
KeyCode::Home => {
form.name_input.move_start();
return true;
}
KeyCode::End => {
form.name_input.move_end();
return true;
}
KeyCode::Char(c) => {
form.clear_error();
form.name_input.insert(c);
return true;
}
_ => {}
}
false
}
fn save_group(app: &mut App) -> Result<(), String> {
app.group_form.validate()?;
let name = app.group_form.name_input.value().trim().to_string();
let category_service = CategoryService::new(app.storage);
if let Some(group_id) = app.group_form.editing_id {
category_service
.update_group(group_id, Some(&name))
.map_err(|e| e.to_string())?;
app.close_dialog();
app.set_status(format!("Category group '{}' updated", name));
} else {
category_service
.create_group(&name)
.map_err(|e| e.to_string())?;
app.storage.categories.save().map_err(|e| e.to_string())?;
app.close_dialog();
app.set_status(format!("Category group '{}' created", name));
}
Ok(())
}