1use ratatui::{
7 layout::{Constraint, Direction, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
11 Frame,
12};
13
14use crate::models::{Account, AccountType, Money};
15use crate::tui::app::App;
16use crate::tui::layout::centered_rect;
17use crate::tui::widgets::input::TextInput;
18
19const ACCOUNT_TYPES: &[AccountType] = &[
21 AccountType::Checking,
22 AccountType::Savings,
23 AccountType::Credit,
24 AccountType::Cash,
25 AccountType::Investment,
26 AccountType::LineOfCredit,
27 AccountType::Other,
28];
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum AccountField {
33 #[default]
34 Name,
35 AccountType,
36 StartingBalance,
37 OnBudget,
38}
39
40impl AccountField {
41 pub fn next(self) -> Self {
43 match self {
44 Self::Name => Self::AccountType,
45 Self::AccountType => Self::StartingBalance,
46 Self::StartingBalance => Self::OnBudget,
47 Self::OnBudget => Self::Name,
48 }
49 }
50
51 pub fn prev(self) -> Self {
53 match self {
54 Self::Name => Self::OnBudget,
55 Self::AccountType => Self::Name,
56 Self::StartingBalance => Self::AccountType,
57 Self::OnBudget => Self::StartingBalance,
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct AccountFormState {
65 pub focused_field: AccountField,
67
68 pub name_input: TextInput,
70
71 pub account_type_index: usize,
73
74 pub balance_input: TextInput,
76
77 pub on_budget: bool,
79
80 pub is_edit: bool,
82
83 pub editing_account_id: Option<crate::models::AccountId>,
85
86 pub error_message: Option<String>,
88}
89
90impl Default for AccountFormState {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl AccountFormState {
97 pub fn new() -> Self {
99 Self {
100 focused_field: AccountField::Name,
101 name_input: TextInput::new().label("Name").placeholder("Account name"),
102 account_type_index: 0, balance_input: TextInput::new()
104 .label("Balance")
105 .placeholder("0.00")
106 .content("0.00"),
107 on_budget: true,
108 is_edit: false,
109 editing_account_id: None,
110 error_message: None,
111 }
112 }
113
114 pub fn from_account(account: &Account) -> Self {
116 let account_type_index = ACCOUNT_TYPES
118 .iter()
119 .position(|t| *t == account.account_type)
120 .unwrap_or(0);
121
122 Self {
123 focused_field: AccountField::Name,
124 name_input: TextInput::new().label("Name").content(&account.name),
125 account_type_index,
126 balance_input: TextInput::new().label("Balance").content(format!(
127 "{:.2}",
128 account.starting_balance.cents() as f64 / 100.0
129 )),
130 on_budget: account.on_budget,
131 is_edit: true,
132 editing_account_id: Some(account.id),
133 error_message: None,
134 }
135 }
136
137 pub fn next_field(&mut self) {
139 self.focused_field = self.focused_field.next();
140 }
141
142 pub fn prev_field(&mut self) {
144 self.focused_field = self.focused_field.prev();
145 }
146
147 pub fn set_focus(&mut self, field: AccountField) {
149 self.focused_field = field;
150 }
151
152 pub fn focused_input(&mut self) -> Option<&mut TextInput> {
154 match self.focused_field {
155 AccountField::Name => Some(&mut self.name_input),
156 AccountField::StartingBalance => Some(&mut self.balance_input),
157 _ => None,
158 }
159 }
160
161 pub fn selected_account_type(&self) -> AccountType {
163 ACCOUNT_TYPES
164 .get(self.account_type_index)
165 .copied()
166 .unwrap_or(AccountType::Checking)
167 }
168
169 pub fn next_account_type(&mut self) {
171 self.account_type_index = (self.account_type_index + 1) % ACCOUNT_TYPES.len();
172 }
173
174 pub fn prev_account_type(&mut self) {
176 if self.account_type_index == 0 {
177 self.account_type_index = ACCOUNT_TYPES.len() - 1;
178 } else {
179 self.account_type_index -= 1;
180 }
181 }
182
183 pub fn toggle_on_budget(&mut self) {
185 self.on_budget = !self.on_budget;
186 }
187
188 pub fn validate(&self) -> Result<(), String> {
190 let name = self.name_input.value().trim();
191 if name.is_empty() {
192 return Err("Account name is required".to_string());
193 }
194 if name.len() > 100 {
195 return Err("Account name too long (max 100 chars)".to_string());
196 }
197
198 let balance_str = self.balance_input.value().trim();
200 if !balance_str.is_empty() && Money::parse(balance_str).is_err() {
201 return Err("Invalid balance format".to_string());
202 }
203
204 Ok(())
205 }
206
207 pub fn build_account(&self) -> Result<Account, String> {
209 self.validate()?;
210
211 let name = self.name_input.value().trim().to_string();
212 let account_type = self.selected_account_type();
213
214 let balance_str = self.balance_input.value().trim();
215 let starting_balance = if balance_str.is_empty() {
216 Money::zero()
217 } else {
218 Money::parse(balance_str).map_err(|_| "Invalid balance")?
219 };
220
221 let mut account = Account::with_starting_balance(name, account_type, starting_balance);
222 account.on_budget = self.on_budget;
223
224 Ok(account)
225 }
226
227 pub fn clear_error(&mut self) {
229 self.error_message = None;
230 }
231
232 pub fn set_error(&mut self, msg: impl Into<String>) {
234 self.error_message = Some(msg.into());
235 }
236}
237
238pub fn render(frame: &mut Frame, app: &mut App) {
240 let area = centered_rect(60, 50, frame.area());
241
242 frame.render_widget(Clear, area);
244
245 let title = if app.account_form.is_edit {
246 " Edit Account "
247 } else {
248 " Add Account "
249 };
250
251 let block = Block::default()
252 .title(title)
253 .title_style(
254 Style::default()
255 .fg(Color::Cyan)
256 .add_modifier(Modifier::BOLD),
257 )
258 .borders(Borders::ALL)
259 .border_style(Style::default().fg(Color::Cyan));
260
261 frame.render_widget(block, area);
262
263 let inner = Rect {
265 x: area.x + 2,
266 y: area.y + 1,
267 width: area.width.saturating_sub(4),
268 height: area.height.saturating_sub(2),
269 };
270
271 let chunks = Layout::default()
273 .direction(Direction::Vertical)
274 .constraints([
275 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
286 .split(inner);
287
288 let name_value = app.account_form.name_input.value().to_string();
290 let name_focused = app.account_form.focused_field == AccountField::Name;
291 let name_cursor = app.account_form.name_input.cursor;
292 let name_placeholder = app.account_form.name_input.placeholder.clone();
293
294 let balance_value = app.account_form.balance_input.value().to_string();
295 let balance_focused = app.account_form.focused_field == AccountField::StartingBalance;
296 let balance_cursor = app.account_form.balance_input.cursor;
297 let balance_placeholder = app.account_form.balance_input.placeholder.clone();
298
299 let type_focused = app.account_form.focused_field == AccountField::AccountType;
300 let budget_focused = app.account_form.focused_field == AccountField::OnBudget;
301 let on_budget = app.account_form.on_budget;
302 let error_message = app.account_form.error_message.clone();
303
304 render_text_field(
306 frame,
307 chunks[0],
308 "Name",
309 &name_value,
310 name_focused,
311 name_cursor,
312 &name_placeholder,
313 );
314
315 let type_label_style = if type_focused {
317 Style::default()
318 .fg(Color::Cyan)
319 .add_modifier(Modifier::BOLD)
320 } else {
321 Style::default().fg(Color::Yellow)
322 };
323 let type_label = Paragraph::new(Line::from(vec![
324 Span::styled("Type: ", type_label_style),
325 Span::styled("(↑/↓ to change)", Style::default().fg(Color::White)),
326 ]));
327 frame.render_widget(type_label, chunks[2]);
328
329 render_account_type_list(frame, app, chunks[3]);
331
332 render_text_field(
334 frame,
335 chunks[4],
336 "Balance",
337 &balance_value,
338 balance_focused,
339 balance_cursor,
340 &balance_placeholder,
341 );
342
343 let budget_label_style = if budget_focused {
345 Style::default()
346 .fg(Color::Cyan)
347 .add_modifier(Modifier::BOLD)
348 } else {
349 Style::default().fg(Color::Yellow)
350 };
351 let budget_value = if on_budget { "[x] Yes" } else { "[ ] No" };
352 let budget_hint = if budget_focused {
353 " (Space to toggle)"
354 } else {
355 ""
356 };
357 let budget_line = Line::from(vec![
358 Span::styled("On Budget: ", budget_label_style),
359 Span::styled(budget_value, Style::default().fg(Color::White)),
360 Span::styled(budget_hint, Style::default().fg(Color::White)),
361 ]);
362 frame.render_widget(Paragraph::new(budget_line), chunks[5]);
363
364 if let Some(ref error) = error_message {
366 let error_line = Line::from(Span::styled(
367 error.as_str(),
368 Style::default().fg(Color::Red),
369 ));
370 frame.render_widget(Paragraph::new(error_line), chunks[7]);
371 }
372
373 let hints = Line::from(vec![
375 Span::styled("[Tab]", Style::default().fg(Color::White)),
376 Span::raw(" Next "),
377 Span::styled("[Enter]", Style::default().fg(Color::Green)),
378 Span::raw(" Save "),
379 Span::styled("[Esc]", Style::default().fg(Color::Red)),
380 Span::raw(" Cancel"),
381 ]);
382 frame.render_widget(Paragraph::new(hints), chunks[8]);
383}
384
385fn render_text_field(
387 frame: &mut Frame,
388 area: Rect,
389 label: &str,
390 value: &str,
391 focused: bool,
392 cursor: usize,
393 placeholder: &str,
394) {
395 let label_style = if focused {
396 Style::default()
397 .fg(Color::Cyan)
398 .add_modifier(Modifier::BOLD)
399 } else {
400 Style::default().fg(Color::Yellow)
401 };
402
403 let label_span = Span::styled(format!("{}: ", label), label_style);
404
405 let value_style = Style::default().fg(Color::White);
406
407 let display_value = if value.is_empty() && !focused {
408 placeholder.to_string()
409 } else {
410 value.to_string()
411 };
412
413 let mut spans = vec![label_span];
414
415 if focused {
416 let cursor_pos = cursor.min(display_value.len());
417 let (before, after) = display_value.split_at(cursor_pos);
418
419 spans.push(Span::styled(before.to_string(), value_style));
420
421 let cursor_char = after.chars().next().unwrap_or(' ');
422 spans.push(Span::styled(
423 cursor_char.to_string(),
424 Style::default().fg(Color::Black).bg(Color::Cyan),
425 ));
426
427 if after.len() > 1 {
428 spans.push(Span::styled(after[1..].to_string(), value_style));
429 }
430 } else {
431 spans.push(Span::styled(display_value, value_style));
432 }
433
434 frame.render_widget(Paragraph::new(Line::from(spans)), area);
435}
436
437fn render_account_type_list(frame: &mut Frame, app: &mut App, area: Rect) {
439 let form = &app.account_form;
440 let focused = form.focused_field == AccountField::AccountType;
441
442 let items: Vec<ListItem> = ACCOUNT_TYPES
443 .iter()
444 .map(|t| {
445 ListItem::new(Line::from(Span::styled(
446 format!(" {}", t),
447 Style::default().fg(Color::White),
448 )))
449 })
450 .collect();
451
452 let list = List::new(items)
453 .highlight_style(
454 Style::default()
455 .bg(Color::DarkGray)
456 .add_modifier(Modifier::BOLD),
457 )
458 .highlight_symbol("▶ ");
459
460 let mut state = ListState::default();
461 state.select(Some(form.account_type_index));
462
463 if focused {
464 frame.render_stateful_widget(list, area, &mut state);
465 } else {
466 let hint = Paragraph::new(" (Tab to this field to select)")
468 .style(Style::default().fg(Color::White));
469 frame.render_widget(hint, area);
470 }
471}
472
473pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
475 use crossterm::event::{KeyCode, KeyModifiers};
476
477 let form = &mut app.account_form;
478
479 match key.code {
480 KeyCode::Esc => {
481 app.close_dialog();
482 return true;
483 }
484
485 KeyCode::Tab => {
486 if key.modifiers.contains(KeyModifiers::SHIFT) {
487 form.prev_field();
488 } else {
489 form.next_field();
490 }
491 return true;
492 }
493
494 KeyCode::BackTab => {
495 form.prev_field();
496 return true;
497 }
498
499 KeyCode::Enter => {
500 if let Err(e) = save_account(app) {
502 app.account_form.set_error(e);
503 }
504 return true;
505 }
506
507 KeyCode::Up => {
508 if form.focused_field == AccountField::AccountType {
509 form.prev_account_type();
510 return true;
511 }
512 }
513
514 KeyCode::Down => {
515 if form.focused_field == AccountField::AccountType {
516 form.next_account_type();
517 return true;
518 }
519 }
520
521 KeyCode::Char(' ') => {
522 if form.focused_field == AccountField::OnBudget {
523 form.toggle_on_budget();
524 return true;
525 }
526 }
528
529 KeyCode::Backspace => {
530 form.clear_error();
531 if let Some(input) = form.focused_input() {
532 input.backspace();
533 }
534 return true;
535 }
536
537 KeyCode::Delete => {
538 form.clear_error();
539 if let Some(input) = form.focused_input() {
540 input.delete();
541 }
542 return true;
543 }
544
545 KeyCode::Left => {
546 if let Some(input) = form.focused_input() {
547 input.move_left();
548 }
549 return true;
550 }
551
552 KeyCode::Right => {
553 if let Some(input) = form.focused_input() {
554 input.move_right();
555 }
556 return true;
557 }
558
559 KeyCode::Home => {
560 if let Some(input) = form.focused_input() {
561 input.move_start();
562 }
563 return true;
564 }
565
566 KeyCode::End => {
567 if let Some(input) = form.focused_input() {
568 input.move_end();
569 }
570 return true;
571 }
572
573 KeyCode::Char(c) => {
574 form.clear_error();
575 if let Some(input) = form.focused_input() {
576 input.insert(c);
577 }
578 return true;
579 }
580
581 _ => {}
582 }
583
584 false
585}
586
587fn save_account(app: &mut App) -> Result<(), String> {
589 app.account_form.validate()?;
591
592 let is_edit = app.account_form.is_edit;
593 let editing_id = app.account_form.editing_account_id;
594
595 if is_edit {
596 if let Some(account_id) = editing_id {
598 if let Ok(Some(mut existing)) = app.storage.accounts.get(account_id) {
599 existing.name = app.account_form.name_input.value().trim().to_string();
600 existing.account_type = app.account_form.selected_account_type();
601 existing.on_budget = app.account_form.on_budget;
602
603 let balance_str = app.account_form.balance_input.value().trim();
605 existing.starting_balance = if balance_str.is_empty() {
606 Money::zero()
607 } else {
608 Money::parse(balance_str).map_err(|_| "Invalid balance")?
609 };
610
611 existing.updated_at = chrono::Utc::now();
612
613 let account_name = existing.name.clone();
614 app.storage
615 .accounts
616 .upsert(existing)
617 .map_err(|e| e.to_string())?;
618
619 app.storage.accounts.save().map_err(|e| e.to_string())?;
620 app.close_dialog();
621 app.set_status(format!("Account '{}' updated", account_name));
622 }
623 }
624 } else {
625 let account = app.account_form.build_account()?;
627 let account_name = account.name.clone();
628
629 app.storage
631 .accounts
632 .upsert(account)
633 .map_err(|e| e.to_string())?;
634
635 app.storage.accounts.save().map_err(|e| e.to_string())?;
637
638 app.close_dialog();
640 app.set_status(format!("Account '{}' created", account_name));
641 }
642
643 Ok(())
644}