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