1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum BudgetHeaderDisplay {
36 #[default]
38 AvailableToBudget,
39 Checking,
41 Savings,
43 Credit,
45 Cash,
47 Investment,
49 LineOfCredit,
51 Other,
53}
54
55impl BudgetHeaderDisplay {
56 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub enum FocusedPanel {
102 #[default]
103 Sidebar,
104 Main,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum InputMode {
110 #[default]
111 Normal,
112 Editing,
113 Command,
114}
115
116#[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
140pub struct App<'a> {
142 pub storage: &'a Storage,
144
145 pub settings: &'a Settings,
147
148 pub paths: &'a EnvelopePaths,
150
151 pub should_quit: bool,
153
154 pub active_view: ActiveView,
156
157 pub focused_panel: FocusedPanel,
159
160 pub input_mode: InputMode,
162
163 pub active_dialog: ActiveDialog,
165
166 pub selected_account: Option<AccountId>,
168
169 pub selected_account_index: usize,
171
172 pub selected_transaction: Option<TransactionId>,
174
175 pub selected_transaction_index: usize,
177
178 pub selected_category: Option<CategoryId>,
180
181 pub selected_category_index: usize,
183
184 pub current_period: BudgetPeriod,
186
187 pub budget_header_display: BudgetHeaderDisplay,
189
190 pub show_archived: bool,
192
193 pub multi_select_mode: bool,
195
196 pub selected_transactions: Vec<TransactionId>,
198
199 pub scroll_offset: usize,
201
202 pub status_message: Option<String>,
204
205 pub command_input: String,
207
208 pub command_results: Vec<usize>,
210
211 pub selected_command_index: usize,
213
214 pub transaction_form: TransactionFormState,
216
217 pub move_funds_state: MoveFundsState,
219
220 pub bulk_categorize_state: BulkCategorizeState,
222
223 pub reconciliation_state: ReconciliationState,
225
226 pub reconcile_start_state: ReconcileStartState,
228
229 pub adjustment_dialog_state: AdjustmentDialogState,
231
232 pub account_form: AccountFormState,
234
235 pub category_form: CategoryFormState,
237
238 pub group_form: GroupFormState,
240
241 pub budget_dialog_state: BudgetDialogState,
243
244 pub pending_g: bool,
246}
247
248impl<'a> App<'a> {
249 pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
251 let selected_account = storage
253 .accounts
254 .get_active()
255 .ok()
256 .and_then(|accounts| accounts.first().map(|a| a.id));
257
258 Self {
259 storage,
260 settings,
261 paths,
262 should_quit: false,
263 active_view: ActiveView::default(),
264 focused_panel: FocusedPanel::default(),
265 input_mode: InputMode::default(),
266 active_dialog: ActiveDialog::default(),
267 selected_account,
268 selected_account_index: 0,
269 selected_transaction: None,
270 selected_transaction_index: 0,
271 selected_category: None,
272 selected_category_index: 0,
273 current_period: BudgetPeriod::current_month(),
274 budget_header_display: BudgetHeaderDisplay::default(),
275 show_archived: false,
276 multi_select_mode: false,
277 selected_transactions: Vec::new(),
278 scroll_offset: 0,
279 status_message: None,
280 command_input: String::new(),
281 command_results: Vec::new(),
282 selected_command_index: 0,
283 transaction_form: TransactionFormState::new(),
284 move_funds_state: MoveFundsState::new(),
285 bulk_categorize_state: BulkCategorizeState::new(),
286 reconciliation_state: ReconciliationState::new(),
287 reconcile_start_state: ReconcileStartState::new(),
288 adjustment_dialog_state: AdjustmentDialogState::default(),
289 account_form: AccountFormState::new(),
290 category_form: CategoryFormState::new(),
291 group_form: GroupFormState::new(),
292 budget_dialog_state: BudgetDialogState::new(),
293 pending_g: false,
294 }
295 }
296
297 pub fn quit(&mut self) {
299 self.should_quit = true;
300 }
301
302 pub fn set_status(&mut self, message: impl Into<String>) {
304 self.status_message = Some(message.into());
305 }
306
307 pub fn clear_status(&mut self) {
309 self.status_message = None;
310 }
311
312 pub fn switch_view(&mut self, view: ActiveView) {
314 self.active_view = view;
315 self.scroll_offset = 0;
316
317 match view {
319 ActiveView::Accounts => {
320 self.selected_account_index = 0;
321 if let Ok(accounts) = self.storage.accounts.get_active() {
323 self.selected_account = accounts.first().map(|a| a.id);
324 }
325 }
326 ActiveView::Register => {
327 self.selected_transaction_index = 0;
328 if let Some(account_id) = self.selected_account {
330 let mut txns = self
331 .storage
332 .transactions
333 .get_by_account(account_id)
334 .unwrap_or_default();
335 txns.sort_by(|a, b| b.date.cmp(&a.date));
336 self.selected_transaction = txns.first().map(|t| t.id);
337 }
338 }
339 ActiveView::Budget => {
340 self.selected_category_index = 0;
341 let groups = self.storage.categories.get_all_groups().unwrap_or_default();
343 let all_categories = self
344 .storage
345 .categories
346 .get_all_categories()
347 .unwrap_or_default();
348 for group in &groups {
350 if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
351 self.selected_category = Some(cat.id);
352 break;
353 }
354 }
355 }
356 ActiveView::Reports => {}
357 ActiveView::Reconcile => {
358 if let Some(account_id) = self.selected_account {
360 self.reconciliation_state.init_for_account(account_id);
361 }
362 }
363 }
364 }
365
366 pub fn toggle_panel_focus(&mut self) {
368 self.focused_panel = match self.focused_panel {
369 FocusedPanel::Sidebar => FocusedPanel::Main,
370 FocusedPanel::Main => FocusedPanel::Sidebar,
371 };
372 if self.focused_panel == FocusedPanel::Main {
374 self.ensure_selection_initialized();
375 }
376 }
377
378 pub fn ensure_selection_initialized(&mut self) {
380 match self.active_view {
381 ActiveView::Accounts => {
382 if self.selected_account.is_none() {
383 if let Ok(accounts) = self.storage.accounts.get_active() {
384 self.selected_account = accounts.first().map(|a| a.id);
385 }
386 }
387 }
388 ActiveView::Register => {
389 if self.selected_transaction.is_none() {
390 if let Some(account_id) = self.selected_account {
391 let mut txns = self
392 .storage
393 .transactions
394 .get_by_account(account_id)
395 .unwrap_or_default();
396 txns.sort_by(|a, b| b.date.cmp(&a.date));
397 self.selected_transaction = txns.first().map(|t| t.id);
398 }
399 }
400 }
401 ActiveView::Budget => {
402 if self.selected_category.is_none() {
403 let groups = self.storage.categories.get_all_groups().unwrap_or_default();
404 let all_categories = self
405 .storage
406 .categories
407 .get_all_categories()
408 .unwrap_or_default();
409 for group in &groups {
410 if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
411 self.selected_category = Some(cat.id);
412 break;
413 }
414 }
415 }
416 }
417 _ => {}
418 }
419 }
420
421 pub fn open_dialog(&mut self, dialog: ActiveDialog) {
423 self.active_dialog = dialog.clone();
424 match &dialog {
425 ActiveDialog::CommandPalette => {
426 self.command_input.clear();
427 self.input_mode = InputMode::Command;
428 }
429 ActiveDialog::AddTransaction => {
430 self.transaction_form = TransactionFormState::new();
432 self.transaction_form
433 .set_focus(super::dialogs::transaction::TransactionField::Date);
434 self.input_mode = InputMode::Editing;
435 }
436 ActiveDialog::EditTransaction(txn_id) => {
437 if let Ok(Some(txn)) = self.storage.transactions.get(*txn_id) {
439 let categories: Vec<_> = self
440 .storage
441 .categories
442 .get_all_categories()
443 .unwrap_or_default()
444 .iter()
445 .map(|c| (c.id, c.name.clone()))
446 .collect();
447 self.transaction_form =
448 TransactionFormState::from_transaction(&txn, &categories);
449 self.transaction_form
450 .set_focus(super::dialogs::transaction::TransactionField::Date);
451 }
452 self.input_mode = InputMode::Editing;
453 }
454 ActiveDialog::AddAccount => {
455 self.account_form = AccountFormState::new();
457 self.account_form
458 .set_focus(super::dialogs::account::AccountField::Name);
459 self.input_mode = InputMode::Editing;
460 }
461 ActiveDialog::EditAccount(account_id) => {
462 if let Ok(Some(account)) = self.storage.accounts.get(*account_id) {
464 self.account_form = AccountFormState::from_account(&account);
465 self.account_form
466 .set_focus(super::dialogs::account::AccountField::Name);
467 }
468 self.input_mode = InputMode::Editing;
469 }
470 ActiveDialog::AddCategory => {
471 self.category_form = CategoryFormState::new();
473 let groups: Vec<_> = self
474 .storage
475 .categories
476 .get_all_groups()
477 .unwrap_or_default()
478 .into_iter()
479 .map(|g| (g.id, g.name))
480 .collect();
481 self.category_form.init_with_groups(groups);
482 self.input_mode = InputMode::Editing;
483 }
484 ActiveDialog::EditCategory(category_id) => {
485 if let Ok(Some(category)) = self.storage.categories.get_category(*category_id) {
487 let groups: Vec<_> = self
488 .storage
489 .categories
490 .get_all_groups()
491 .unwrap_or_default()
492 .into_iter()
493 .map(|g| (g.id, g.name.clone()))
494 .collect();
495 self.category_form.init_for_edit(&category, groups);
496 }
497 self.input_mode = InputMode::Editing;
498 }
499 ActiveDialog::AddGroup => {
500 self.group_form = GroupFormState::new();
502 self.input_mode = InputMode::Editing;
503 }
504 ActiveDialog::EditGroup(group_id) => {
505 if let Ok(Some(group)) = self.storage.categories.get_group(*group_id) {
507 self.group_form = GroupFormState::new();
508 self.group_form.init_for_edit(&group);
509 }
510 self.input_mode = InputMode::Editing;
511 }
512 ActiveDialog::Budget => {
513 if let Some(category_id) = self.selected_category {
515 if let Ok(Some(category)) = self.storage.categories.get_category(category_id) {
516 let budget_service = crate::services::BudgetService::new(self.storage);
517 let summary = budget_service
518 .get_category_summary(category_id, &self.current_period)
519 .unwrap_or_else(|_| {
520 crate::models::CategoryBudgetSummary::empty(category_id)
521 });
522 let suggested = budget_service
523 .get_suggested_budget(category_id, &self.current_period)
524 .ok()
525 .flatten();
526 let existing_target = self
527 .storage
528 .targets
529 .get_for_category(category_id)
530 .ok()
531 .flatten();
532 self.budget_dialog_state.init_for_category(
533 category_id,
534 category.name,
535 summary.budgeted,
536 suggested,
537 existing_target.as_ref(),
538 );
539 self.input_mode = InputMode::Editing;
540 }
541 }
542 }
543 _ => {}
544 }
545 }
546
547 pub fn close_dialog(&mut self) {
549 self.active_dialog = ActiveDialog::None;
550 self.input_mode = InputMode::Normal;
551 }
552
553 pub fn has_dialog(&self) -> bool {
555 !matches!(self.active_dialog, ActiveDialog::None)
556 }
557
558 pub fn move_up(&mut self) {
560 match self.focused_panel {
561 FocusedPanel::Sidebar => {
562 if self.selected_account_index > 0 {
563 self.selected_account_index -= 1;
564 }
565 }
566 FocusedPanel::Main => match self.active_view {
567 ActiveView::Register => {
568 if self.selected_transaction_index > 0 {
569 self.selected_transaction_index -= 1;
570 }
571 }
572 ActiveView::Budget => {
573 if self.selected_category_index > 0 {
574 self.selected_category_index -= 1;
575 }
576 }
577 _ => {}
578 },
579 }
580 }
581
582 pub fn move_down(&mut self, max: usize) {
584 match self.focused_panel {
585 FocusedPanel::Sidebar => {
586 if self.selected_account_index < max.saturating_sub(1) {
587 self.selected_account_index += 1;
588 }
589 }
590 FocusedPanel::Main => match self.active_view {
591 ActiveView::Register => {
592 if self.selected_transaction_index < max.saturating_sub(1) {
593 self.selected_transaction_index += 1;
594 }
595 }
596 ActiveView::Budget => {
597 if self.selected_category_index < max.saturating_sub(1) {
598 self.selected_category_index += 1;
599 }
600 }
601 _ => {}
602 },
603 }
604 }
605
606 pub fn prev_period(&mut self) {
608 self.current_period = self.current_period.prev();
609 }
610
611 pub fn next_period(&mut self) {
613 self.current_period = self.current_period.next();
614 }
615
616 pub fn toggle_multi_select(&mut self) {
618 self.multi_select_mode = !self.multi_select_mode;
619 if !self.multi_select_mode {
620 self.selected_transactions.clear();
621 }
622 }
623
624 pub fn toggle_transaction_selection(&mut self) {
626 if let Some(txn_id) = self.selected_transaction {
627 if self.selected_transactions.contains(&txn_id) {
628 self.selected_transactions.retain(|&id| id != txn_id);
629 } else {
630 self.selected_transactions.push(txn_id);
631 }
632 }
633 }
634}