envelope_cli/tui/
app.rs

1//! Application state for the TUI
2//!
3//! The App struct holds all state needed for rendering and handling events.
4
5use crate::config::paths::EnvelopePaths;
6use crate::config::settings::Settings;
7use crate::models::{AccountId, BudgetPeriod, CategoryGroupId, CategoryId, TransactionId};
8use crate::storage::Storage;
9
10use super::dialogs::account::AccountFormState;
11use super::dialogs::adjustment::AdjustmentDialogState;
12use super::dialogs::budget::BudgetDialogState;
13use super::dialogs::bulk_categorize::BulkCategorizeState;
14use super::dialogs::category::CategoryFormState;
15use super::dialogs::group::GroupFormState;
16use super::dialogs::move_funds::MoveFundsState;
17use super::dialogs::reconcile_start::ReconcileStartState;
18use super::dialogs::transaction::TransactionFormState;
19use super::dialogs::unlock_confirm::UnlockConfirmState;
20use super::views::reconcile::ReconciliationState;
21
22/// Which view is currently active
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ActiveView {
25    #[default]
26    Accounts,
27    Register,
28    Budget,
29    Reports,
30    Reconcile,
31}
32
33/// What to display in the budget header
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum BudgetHeaderDisplay {
36    /// Show Available to Budget / Overspent (default)
37    #[default]
38    AvailableToBudget,
39    /// Show total checking account balance
40    Checking,
41    /// Show total savings account balance
42    Savings,
43    /// Show total credit card balance
44    Credit,
45    /// Show total cash balance
46    Cash,
47    /// Show total investment balance
48    Investment,
49    /// Show total line of credit balance
50    LineOfCredit,
51    /// Show total for other account types
52    Other,
53}
54
55impl BudgetHeaderDisplay {
56    /// Cycle to the next display mode
57    pub fn next(self) -> Self {
58        match self {
59            Self::AvailableToBudget => Self::Checking,
60            Self::Checking => Self::Savings,
61            Self::Savings => Self::Credit,
62            Self::Credit => Self::Cash,
63            Self::Cash => Self::Investment,
64            Self::Investment => Self::LineOfCredit,
65            Self::LineOfCredit => Self::Other,
66            Self::Other => Self::AvailableToBudget,
67        }
68    }
69
70    /// Cycle to the previous display mode
71    pub fn prev(self) -> Self {
72        match self {
73            Self::AvailableToBudget => Self::Other,
74            Self::Checking => Self::AvailableToBudget,
75            Self::Savings => Self::Checking,
76            Self::Credit => Self::Savings,
77            Self::Cash => Self::Credit,
78            Self::Investment => Self::Cash,
79            Self::LineOfCredit => Self::Investment,
80            Self::Other => Self::LineOfCredit,
81        }
82    }
83
84    /// Get the display label for this mode
85    pub fn label(&self) -> &'static str {
86        match self {
87            Self::AvailableToBudget => "Available to Budget",
88            Self::Checking => "Checking",
89            Self::Savings => "Savings",
90            Self::Credit => "Credit Cards",
91            Self::Cash => "Cash",
92            Self::Investment => "Investment",
93            Self::LineOfCredit => "Line of Credit",
94            Self::Other => "Other",
95        }
96    }
97}
98
99/// Which panel currently has focus
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub enum FocusedPanel {
102    #[default]
103    Sidebar,
104    Main,
105}
106
107/// Mode of input
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum InputMode {
110    #[default]
111    Normal,
112    Editing,
113    Command,
114}
115
116/// Currently active dialog (if any)
117#[derive(Debug, Clone, PartialEq, Eq, Default)]
118pub enum ActiveDialog {
119    #[default]
120    None,
121    AddTransaction,
122    EditTransaction(TransactionId),
123    AddAccount,
124    EditAccount(AccountId),
125    AddCategory,
126    EditCategory(CategoryId),
127    AddGroup,
128    EditGroup(CategoryGroupId),
129    MoveFunds,
130    CommandPalette,
131    Help,
132    Confirm(String),
133    BulkCategorize,
134    ReconcileStart,
135    UnlockConfirm(UnlockConfirmState),
136    Adjustment,
137    Budget,
138}
139
140/// Main application state
141pub struct App<'a> {
142    /// The storage layer
143    pub storage: &'a Storage,
144
145    /// Application settings
146    pub settings: &'a Settings,
147
148    /// Paths configuration
149    pub paths: &'a EnvelopePaths,
150
151    /// Whether the app should quit
152    pub should_quit: bool,
153
154    /// Currently active view
155    pub active_view: ActiveView,
156
157    /// Which panel is focused
158    pub focused_panel: FocusedPanel,
159
160    /// Current input mode
161    pub input_mode: InputMode,
162
163    /// Currently active dialog
164    pub active_dialog: ActiveDialog,
165
166    /// Selected account (if any)
167    pub selected_account: Option<AccountId>,
168
169    /// Selected account index in the list
170    pub selected_account_index: usize,
171
172    /// Selected transaction (if any)
173    pub selected_transaction: Option<TransactionId>,
174
175    /// Selected transaction index in the register
176    pub selected_transaction_index: usize,
177
178    /// Selected category (for budget view)
179    pub selected_category: Option<CategoryId>,
180
181    /// Selected category index
182    pub selected_category_index: usize,
183
184    /// Current budget period being viewed
185    pub current_period: BudgetPeriod,
186
187    /// What to display in the budget header (toggle between ATB and account balances)
188    pub budget_header_display: BudgetHeaderDisplay,
189
190    /// Show archived accounts
191    pub show_archived: bool,
192
193    /// Multi-selection mode (for bulk operations)
194    pub multi_select_mode: bool,
195
196    /// Selected transaction IDs for bulk operations
197    pub selected_transactions: Vec<TransactionId>,
198
199    /// Scroll offset for the main view
200    pub scroll_offset: usize,
201
202    /// Status message to display
203    pub status_message: Option<String>,
204
205    /// Command palette input
206    pub command_input: String,
207
208    /// Filtered commands for palette
209    pub command_results: Vec<usize>,
210
211    /// Selected command index in palette
212    pub selected_command_index: usize,
213
214    /// Transaction form state
215    pub transaction_form: TransactionFormState,
216
217    /// Move funds dialog state
218    pub move_funds_state: MoveFundsState,
219
220    /// Bulk categorize dialog state
221    pub bulk_categorize_state: BulkCategorizeState,
222
223    /// Reconciliation view state
224    pub reconciliation_state: ReconciliationState,
225
226    /// Reconcile start dialog state
227    pub reconcile_start_state: ReconcileStartState,
228
229    /// Adjustment dialog state
230    pub adjustment_dialog_state: AdjustmentDialogState,
231
232    /// Account form dialog state
233    pub account_form: AccountFormState,
234
235    /// Category form dialog state
236    pub category_form: CategoryFormState,
237
238    /// Group form dialog state
239    pub group_form: GroupFormState,
240
241    /// Unified budget dialog state (period budget + target)
242    pub budget_dialog_state: BudgetDialogState,
243}
244
245impl<'a> App<'a> {
246    /// Create a new App instance
247    pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
248        // Initialize selected_account to first account (default view is Accounts)
249        let selected_account = storage
250            .accounts
251            .get_active()
252            .ok()
253            .and_then(|accounts| accounts.first().map(|a| a.id));
254
255        Self {
256            storage,
257            settings,
258            paths,
259            should_quit: false,
260            active_view: ActiveView::default(),
261            focused_panel: FocusedPanel::default(),
262            input_mode: InputMode::default(),
263            active_dialog: ActiveDialog::default(),
264            selected_account,
265            selected_account_index: 0,
266            selected_transaction: None,
267            selected_transaction_index: 0,
268            selected_category: None,
269            selected_category_index: 0,
270            current_period: BudgetPeriod::current_month(),
271            budget_header_display: BudgetHeaderDisplay::default(),
272            show_archived: false,
273            multi_select_mode: false,
274            selected_transactions: Vec::new(),
275            scroll_offset: 0,
276            status_message: None,
277            command_input: String::new(),
278            command_results: Vec::new(),
279            selected_command_index: 0,
280            transaction_form: TransactionFormState::new(),
281            move_funds_state: MoveFundsState::new(),
282            bulk_categorize_state: BulkCategorizeState::new(),
283            reconciliation_state: ReconciliationState::new(),
284            reconcile_start_state: ReconcileStartState::new(),
285            adjustment_dialog_state: AdjustmentDialogState::default(),
286            account_form: AccountFormState::new(),
287            category_form: CategoryFormState::new(),
288            group_form: GroupFormState::new(),
289            budget_dialog_state: BudgetDialogState::new(),
290        }
291    }
292
293    /// Request to quit the application
294    pub fn quit(&mut self) {
295        self.should_quit = true;
296    }
297
298    /// Set a status message
299    pub fn set_status(&mut self, message: impl Into<String>) {
300        self.status_message = Some(message.into());
301    }
302
303    /// Clear the status message
304    pub fn clear_status(&mut self) {
305        self.status_message = None;
306    }
307
308    /// Switch to a different view
309    pub fn switch_view(&mut self, view: ActiveView) {
310        self.active_view = view;
311        self.scroll_offset = 0;
312
313        // Reset selection based on view
314        match view {
315            ActiveView::Accounts => {
316                self.selected_account_index = 0;
317                // Initialize selected_account to first account
318                if let Ok(accounts) = self.storage.accounts.get_active() {
319                    self.selected_account = accounts.first().map(|a| a.id);
320                }
321            }
322            ActiveView::Register => {
323                self.selected_transaction_index = 0;
324                // Initialize selected_transaction to first transaction (sorted by date desc)
325                if let Some(account_id) = self.selected_account {
326                    let mut txns = self
327                        .storage
328                        .transactions
329                        .get_by_account(account_id)
330                        .unwrap_or_default();
331                    txns.sort_by(|a, b| b.date.cmp(&a.date));
332                    self.selected_transaction = txns.first().map(|t| t.id);
333                }
334            }
335            ActiveView::Budget => {
336                self.selected_category_index = 0;
337                // Initialize selected_category to first category (in visual order)
338                let groups = self.storage.categories.get_all_groups().unwrap_or_default();
339                let all_categories = self
340                    .storage
341                    .categories
342                    .get_all_categories()
343                    .unwrap_or_default();
344                // Find first category in visual order (first category in first group)
345                for group in &groups {
346                    if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
347                        self.selected_category = Some(cat.id);
348                        break;
349                    }
350                }
351            }
352            ActiveView::Reports => {}
353            ActiveView::Reconcile => {
354                // Initialize reconciliation state if account is selected
355                if let Some(account_id) = self.selected_account {
356                    self.reconciliation_state.init_for_account(account_id);
357                }
358            }
359        }
360    }
361
362    /// Toggle focus between sidebar and main panel
363    pub fn toggle_panel_focus(&mut self) {
364        self.focused_panel = match self.focused_panel {
365            FocusedPanel::Sidebar => FocusedPanel::Main,
366            FocusedPanel::Main => FocusedPanel::Sidebar,
367        };
368        // Initialize selection when switching to main panel
369        if self.focused_panel == FocusedPanel::Main {
370            self.ensure_selection_initialized();
371        }
372    }
373
374    /// Ensure selection is initialized for the current view
375    pub fn ensure_selection_initialized(&mut self) {
376        match self.active_view {
377            ActiveView::Accounts => {
378                if self.selected_account.is_none() {
379                    if let Ok(accounts) = self.storage.accounts.get_active() {
380                        self.selected_account = accounts.first().map(|a| a.id);
381                    }
382                }
383            }
384            ActiveView::Register => {
385                if self.selected_transaction.is_none() {
386                    if let Some(account_id) = self.selected_account {
387                        let mut txns = self
388                            .storage
389                            .transactions
390                            .get_by_account(account_id)
391                            .unwrap_or_default();
392                        txns.sort_by(|a, b| b.date.cmp(&a.date));
393                        self.selected_transaction = txns.first().map(|t| t.id);
394                    }
395                }
396            }
397            ActiveView::Budget => {
398                if self.selected_category.is_none() {
399                    let groups = self.storage.categories.get_all_groups().unwrap_or_default();
400                    let all_categories = self
401                        .storage
402                        .categories
403                        .get_all_categories()
404                        .unwrap_or_default();
405                    for group in &groups {
406                        if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
407                            self.selected_category = Some(cat.id);
408                            break;
409                        }
410                    }
411                }
412            }
413            _ => {}
414        }
415    }
416
417    /// Open a dialog
418    pub fn open_dialog(&mut self, dialog: ActiveDialog) {
419        self.active_dialog = dialog.clone();
420        match &dialog {
421            ActiveDialog::CommandPalette => {
422                self.command_input.clear();
423                self.input_mode = InputMode::Command;
424            }
425            ActiveDialog::AddTransaction => {
426                // Reset form for new transaction
427                self.transaction_form = TransactionFormState::new();
428                self.transaction_form
429                    .set_focus(super::dialogs::transaction::TransactionField::Date);
430                self.input_mode = InputMode::Editing;
431            }
432            ActiveDialog::EditTransaction(txn_id) => {
433                // Load transaction data into form
434                if let Ok(Some(txn)) = self.storage.transactions.get(*txn_id) {
435                    let categories: Vec<_> = self
436                        .storage
437                        .categories
438                        .get_all_categories()
439                        .unwrap_or_default()
440                        .iter()
441                        .map(|c| (c.id, c.name.clone()))
442                        .collect();
443                    self.transaction_form =
444                        TransactionFormState::from_transaction(&txn, &categories);
445                    self.transaction_form
446                        .set_focus(super::dialogs::transaction::TransactionField::Date);
447                }
448                self.input_mode = InputMode::Editing;
449            }
450            ActiveDialog::AddAccount => {
451                // Reset form for new account
452                self.account_form = AccountFormState::new();
453                self.account_form
454                    .set_focus(super::dialogs::account::AccountField::Name);
455                self.input_mode = InputMode::Editing;
456            }
457            ActiveDialog::EditAccount(account_id) => {
458                // Load account data into form
459                if let Ok(Some(account)) = self.storage.accounts.get(*account_id) {
460                    self.account_form = AccountFormState::from_account(&account);
461                    self.account_form
462                        .set_focus(super::dialogs::account::AccountField::Name);
463                }
464                self.input_mode = InputMode::Editing;
465            }
466            ActiveDialog::AddCategory => {
467                // Reset form for new category and load available groups
468                self.category_form = CategoryFormState::new();
469                let groups: Vec<_> = self
470                    .storage
471                    .categories
472                    .get_all_groups()
473                    .unwrap_or_default()
474                    .into_iter()
475                    .map(|g| (g.id, g.name))
476                    .collect();
477                self.category_form.init_with_groups(groups);
478                self.input_mode = InputMode::Editing;
479            }
480            ActiveDialog::EditCategory(category_id) => {
481                // Load category data into form
482                if let Ok(Some(category)) = self.storage.categories.get_category(*category_id) {
483                    let groups: Vec<_> = self
484                        .storage
485                        .categories
486                        .get_all_groups()
487                        .unwrap_or_default()
488                        .into_iter()
489                        .map(|g| (g.id, g.name.clone()))
490                        .collect();
491                    self.category_form.init_for_edit(&category, groups);
492                }
493                self.input_mode = InputMode::Editing;
494            }
495            ActiveDialog::AddGroup => {
496                // Reset form for new group
497                self.group_form = GroupFormState::new();
498                self.input_mode = InputMode::Editing;
499            }
500            ActiveDialog::EditGroup(group_id) => {
501                // Load group data into form for editing
502                if let Ok(Some(group)) = self.storage.categories.get_group(*group_id) {
503                    self.group_form = GroupFormState::new();
504                    self.group_form.init_for_edit(&group);
505                }
506                self.input_mode = InputMode::Editing;
507            }
508            ActiveDialog::Budget => {
509                // Initialize unified budget dialog for selected category
510                if let Some(category_id) = self.selected_category {
511                    if let Ok(Some(category)) = self.storage.categories.get_category(category_id) {
512                        let budget_service = crate::services::BudgetService::new(self.storage);
513                        let summary = budget_service
514                            .get_category_summary(category_id, &self.current_period)
515                            .unwrap_or_else(|_| {
516                                crate::models::CategoryBudgetSummary::empty(category_id)
517                            });
518                        let suggested = budget_service
519                            .get_suggested_budget(category_id, &self.current_period)
520                            .ok()
521                            .flatten();
522                        let existing_target = self
523                            .storage
524                            .targets
525                            .get_for_category(category_id)
526                            .ok()
527                            .flatten();
528                        self.budget_dialog_state.init_for_category(
529                            category_id,
530                            category.name,
531                            summary.budgeted,
532                            suggested,
533                            existing_target.as_ref(),
534                        );
535                        self.input_mode = InputMode::Editing;
536                    }
537                }
538            }
539            _ => {}
540        }
541    }
542
543    /// Close the current dialog
544    pub fn close_dialog(&mut self) {
545        self.active_dialog = ActiveDialog::None;
546        self.input_mode = InputMode::Normal;
547    }
548
549    /// Check if a dialog is active
550    pub fn has_dialog(&self) -> bool {
551        !matches!(self.active_dialog, ActiveDialog::None)
552    }
553
554    /// Move selection up in the current view
555    pub fn move_up(&mut self) {
556        match self.focused_panel {
557            FocusedPanel::Sidebar => {
558                if self.selected_account_index > 0 {
559                    self.selected_account_index -= 1;
560                }
561            }
562            FocusedPanel::Main => match self.active_view {
563                ActiveView::Register => {
564                    if self.selected_transaction_index > 0 {
565                        self.selected_transaction_index -= 1;
566                    }
567                }
568                ActiveView::Budget => {
569                    if self.selected_category_index > 0 {
570                        self.selected_category_index -= 1;
571                    }
572                }
573                _ => {}
574            },
575        }
576    }
577
578    /// Move selection down in the current view
579    pub fn move_down(&mut self, max: usize) {
580        match self.focused_panel {
581            FocusedPanel::Sidebar => {
582                if self.selected_account_index < max.saturating_sub(1) {
583                    self.selected_account_index += 1;
584                }
585            }
586            FocusedPanel::Main => match self.active_view {
587                ActiveView::Register => {
588                    if self.selected_transaction_index < max.saturating_sub(1) {
589                        self.selected_transaction_index += 1;
590                    }
591                }
592                ActiveView::Budget => {
593                    if self.selected_category_index < max.saturating_sub(1) {
594                        self.selected_category_index += 1;
595                    }
596                }
597                _ => {}
598            },
599        }
600    }
601
602    /// Go to previous budget period
603    pub fn prev_period(&mut self) {
604        self.current_period = self.current_period.prev();
605    }
606
607    /// Go to next budget period
608    pub fn next_period(&mut self) {
609        self.current_period = self.current_period.next();
610    }
611
612    /// Toggle multi-select mode
613    pub fn toggle_multi_select(&mut self) {
614        self.multi_select_mode = !self.multi_select_mode;
615        if !self.multi_select_mode {
616            self.selected_transactions.clear();
617        }
618    }
619
620    /// Toggle selection of current transaction in multi-select mode
621    pub fn toggle_transaction_selection(&mut self) {
622        if let Some(txn_id) = self.selected_transaction {
623            if self.selected_transactions.contains(&txn_id) {
624                self.selected_transactions.retain(|&id| id != txn_id);
625            } else {
626                self.selected_transactions.push(txn_id);
627            }
628        }
629    }
630}